@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +6 -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 +4 -1
- package/dist/privacy-center.d.ts +4 -1
- package/dist/privacy-center.js +3905 -3660
- package/dist/privacy-center.mjs +4172 -3921
- 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/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/constant.ts +25 -0
- package/src/RedactoPrivacyCenter/components/types.ts +32 -1
- 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.ts +213 -63
- package/src/RedactoPrivacyCenter/locales/en/translation.json +3 -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
|
+
}
|
|
@@ -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);
|
|
@@ -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
|
-
|
|
30
|
+
// Rows must be pre-formatted display strings keyed by their (already localized)
|
|
31
|
+
// column header. Each cell is written verbatim so the CSV matches the on-screen
|
|
32
|
+
// table exactly; null/undefined become an empty cell rather than the text "null".
|
|
33
|
+
export const exportTableToCSV = (rows: Record<string, string>[]) => {
|
|
26
34
|
if (!rows || rows.length === 0) {
|
|
27
|
-
console.warn('No data to export');
|
|
28
35
|
return;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const escape = (value: string | null | undefined) => {
|
|
39
|
+
const raw = value ?? '';
|
|
40
|
+
// Defuse spreadsheet formula injection by prefixing risky leading chars.
|
|
41
|
+
const safe =
|
|
42
|
+
raw.length > 0 && CSV_FORMULA_TRIGGERS.includes(raw[0]) ? `'${raw}` : raw;
|
|
43
|
+
return `"${safe.replace(/"/g, '""')}"`;
|
|
44
|
+
};
|
|
36
45
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if ((key === 'given_date' || key === 'valid_till') && value) {
|
|
43
|
-
formattedValue = formatDateTime(String(value));
|
|
44
|
-
}
|
|
45
|
-
return `"${String(formattedValue).replace(/"/g, '""')}"`;
|
|
46
|
-
})
|
|
47
|
-
.join(',');
|
|
48
|
-
});
|
|
46
|
+
const columns = Object.keys(rows[0]);
|
|
47
|
+
const headers = columns.map(escape).join(',');
|
|
48
|
+
const csvRows = rows.map((row) =>
|
|
49
|
+
columns.map((col) => escape(row[col])).join(',')
|
|
50
|
+
);
|
|
49
51
|
|
|
50
52
|
const csvContent = [headers, ...csvRows].join('\n');
|
|
51
53
|
|
|
52
54
|
try {
|
|
53
|
-
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
55
|
+
const blob = new Blob([UTF8_BOM, csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
54
56
|
const url = URL.createObjectURL(blob);
|
|
55
57
|
const link = document.createElement('a');
|
|
56
58
|
link.href = url;
|