@redacto.io/consent-sdk-react 9.2.0-beta.0 → 9.2.0-beta.2

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 (51) hide show
  1. package/.turbo/turbo-build.log +15 -10
  2. package/CHANGELOG.md +12 -0
  3. package/dist/privacy-center.d.mts +51 -1
  4. package/dist/privacy-center.d.ts +51 -1
  5. package/dist/privacy-center.js +3463 -1148
  6. package/dist/privacy-center.mjs +3399 -1084
  7. package/package.json +1 -1
  8. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  9. package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +26 -0
  10. package/src/RedactoPrivacyCenter/api/actions.ts +66 -0
  11. package/src/RedactoPrivacyCenter/api/client.ts +79 -0
  12. package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +222 -0
  13. package/src/RedactoPrivacyCenter/components/Form.tsx +402 -259
  14. package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +492 -0
  15. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +95 -57
  16. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +6 -1
  17. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +56 -0
  18. package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +203 -0
  19. package/src/RedactoPrivacyCenter/components/types.ts +20 -2
  20. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +2 -2
  21. package/src/RedactoPrivacyCenter/lib/api.ts +67 -8
  22. package/src/RedactoPrivacyCenter/lib/constants.ts +5 -0
  23. package/src/RedactoPrivacyCenter/lib/types.ts +74 -0
  24. package/src/RedactoPrivacyCenter/locales/as/translation.json +39 -1
  25. package/src/RedactoPrivacyCenter/locales/bho/translation.json +39 -1
  26. package/src/RedactoPrivacyCenter/locales/bn/translation.json +39 -1
  27. package/src/RedactoPrivacyCenter/locales/brx/translation.json +39 -1
  28. package/src/RedactoPrivacyCenter/locales/doi/translation.json +39 -1
  29. package/src/RedactoPrivacyCenter/locales/en/translation.json +44 -1
  30. package/src/RedactoPrivacyCenter/locales/gom/translation.json +39 -1
  31. package/src/RedactoPrivacyCenter/locales/gu/translation.json +39 -1
  32. package/src/RedactoPrivacyCenter/locales/hi/translation.json +39 -1
  33. package/src/RedactoPrivacyCenter/locales/kn/translation.json +39 -1
  34. package/src/RedactoPrivacyCenter/locales/ks/translation.json +39 -1
  35. package/src/RedactoPrivacyCenter/locales/mai/translation.json +39 -1
  36. package/src/RedactoPrivacyCenter/locales/ml/translation.json +39 -1
  37. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +39 -1
  38. package/src/RedactoPrivacyCenter/locales/mr/translation.json +39 -1
  39. package/src/RedactoPrivacyCenter/locales/ne/translation.json +39 -1
  40. package/src/RedactoPrivacyCenter/locales/or/translation.json +39 -1
  41. package/src/RedactoPrivacyCenter/locales/pa/translation.json +39 -1
  42. package/src/RedactoPrivacyCenter/locales/sa/translation.json +39 -1
  43. package/src/RedactoPrivacyCenter/locales/sat/translation.json +39 -1
  44. package/src/RedactoPrivacyCenter/locales/sd/translation.json +39 -1
  45. package/src/RedactoPrivacyCenter/locales/ta/translation.json +39 -1
  46. package/src/RedactoPrivacyCenter/locales/te/translation.json +39 -1
  47. package/src/RedactoPrivacyCenter/locales/ur/translation.json +39 -1
  48. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +301 -0
  49. package/tests/Form.test.tsx +1144 -0
  50. package/tests/ProductSelectionSection.test.tsx +328 -0
  51. package/tests/RequestSubmit.test.tsx +135 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "9.2.0-beta.0",
3
+ "version": "9.2.0-beta.2",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -4,6 +4,7 @@ import { useInternalNavigation } from './context/NavigationContext';
4
4
  import { PrivacyCenterConsentManagerPage } from './PrivacyCenterConsentManagerPage';
5
5
  import { PrivacyCenterFormPage } from './PrivacyCenterFormPage';
6
6
  import { PrivacyCenterActivityPage } from './PrivacyCenterActivityPage';
7
+ import { PrivacyCenterMyReceiptPage } from './PrivacyCenterMyReceiptPage';
7
8
  import { PrivacyCenterCaseDetailsPage } from './PrivacyCenterCaseDetailsPage';
8
9
  import { NotificationsPage } from './NotificationsPage';
9
10
 
@@ -23,6 +24,8 @@ export function PrivacyCenterHomePage() {
23
24
  return <PrivacyCenterFormPage />;
24
25
  case 'activity':
25
26
  return <PrivacyCenterActivityPage />;
27
+ case 'receipts':
28
+ return <PrivacyCenterMyReceiptPage />;
26
29
  case 'case-details':
27
30
  return <PrivacyCenterCaseDetailsPage />;
28
31
  case 'notifications':
@@ -0,0 +1,26 @@
1
+ import { useEffect } from 'react';
2
+ import i18n from './lib/i18n';
3
+ import ReceiptsList from './components/ReceiptsList';
4
+
5
+ export function PrivacyCenterMyReceiptPage() {
6
+ useEffect(() => {
7
+ if (!i18n.language) {
8
+ i18n.changeLanguage('en');
9
+ }
10
+ }, []);
11
+
12
+ return (
13
+ <div
14
+ style={{
15
+ width: '100%',
16
+ display: 'flex',
17
+ flexDirection: 'column',
18
+ gap: '0.5rem',
19
+ }}
20
+ >
21
+ <div style={{ paddingTop: '0.25rem' }}>
22
+ <ReceiptsList />
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -419,6 +419,72 @@ export async function fetchActivitiesServer(params: {
419
419
  }
420
420
  }
421
421
 
422
+ export async function fetchReceiptsServer(params: {
423
+ organizationId: string;
424
+ workspaceId: string;
425
+ skip?: number;
426
+ limit?: number;
427
+ notice_uuid?: string;
428
+ event_type?: string;
429
+ status?: string;
430
+ created_after?: string;
431
+ created_before?: string;
432
+ language?: string;
433
+ authToken?: string;
434
+ consentClient?: ReturnType<typeof import('./client').createConsentClient>;
435
+ }): Promise<unknown | null> {
436
+ try {
437
+ if (!params.consentClient) {
438
+ throw new Error('Consent client is required');
439
+ }
440
+ // Privacy center endpoints use JWT Bearer tokens, not session-based auth
441
+ const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
442
+
443
+ const result = await params.consentClient.getReceipts(
444
+ baseRequest,
445
+ {
446
+ skip: params.skip,
447
+ limit: params.limit,
448
+ notice_uuid: params.notice_uuid,
449
+ event_type: params.event_type,
450
+ status: params.status,
451
+ created_after: params.created_after,
452
+ created_before: params.created_before,
453
+ language: params.language,
454
+ },
455
+ params.authToken
456
+ );
457
+ return result;
458
+ } catch (error: any) {
459
+ return handleServerActionError(error, 'fetching receipts');
460
+ }
461
+ }
462
+
463
+ export async function fetchReceiptPdfServer(params: {
464
+ organizationId: string;
465
+ workspaceId: string;
466
+ receiptUuid: string;
467
+ authToken?: string;
468
+ consentClient?: ReturnType<typeof import('./client').createConsentClient>;
469
+ }): Promise<Blob | null> {
470
+ try {
471
+ if (!params.consentClient) {
472
+ throw new Error('Consent client is required');
473
+ }
474
+ const baseRequest = buildBaseRequest(params.organizationId, params.workspaceId, params.authToken);
475
+
476
+ const result = await params.consentClient.getReceiptPdf(
477
+ baseRequest,
478
+ params.receiptUuid,
479
+ params.authToken
480
+ );
481
+ return result;
482
+ } catch (error: any) {
483
+ // Re-throw 404s (receipt doesn't exist or not yours) so the UI can show a targeted message
484
+ return handleServerActionError(error, 'downloading receipt PDF', true);
485
+ }
486
+ }
487
+
422
488
  export async function fetchCaseHistoryServer(params: {
423
489
  organizationId: string;
424
490
  workspaceId: string;
@@ -464,6 +464,85 @@ export function createConsentClient(baseUrl: string) {
464
464
  });
465
465
  },
466
466
 
467
+ /**
468
+ * Get receipts (privacy center) — consent receipts for the authenticated user
469
+ */
470
+ async getReceipts(
471
+ baseRequest: BaseRequest,
472
+ params: {
473
+ skip?: number;
474
+ limit?: number;
475
+ notice_uuid?: string;
476
+ event_type?: string;
477
+ status?: string;
478
+ created_after?: string;
479
+ created_before?: string;
480
+ language?: string;
481
+ },
482
+ authToken?: string,
483
+ options?: Omit<ApiFetchOptions, "getAuthToken">
484
+ ): Promise<unknown> {
485
+ const queryParams = new URLSearchParams();
486
+ if (params.skip !== undefined) queryParams.append("skip", params.skip.toString());
487
+ if (params.limit !== undefined) queryParams.append("limit", params.limit.toString());
488
+ if (params.notice_uuid) queryParams.append("notice_uuid", params.notice_uuid);
489
+ if (params.event_type) queryParams.append("event_type", params.event_type);
490
+ if (params.status) queryParams.append("status", params.status);
491
+ if (params.created_after) queryParams.append("created_after", params.created_after);
492
+ if (params.created_before) queryParams.append("created_before", params.created_before);
493
+ if (params.language) queryParams.append("language", params.language);
494
+ const queryString = queryParams.toString();
495
+ const url = privacyCenterUrl(baseRequest, `/receipts${queryString ? `?${queryString}` : ""}`);
496
+ return privacyCenterApiFetch<unknown>(url, {
497
+ ...options,
498
+ method: "GET",
499
+ cache: "no-store",
500
+ authToken: resolveAuthToken(authToken, baseRequest),
501
+ });
502
+ },
503
+
504
+ /**
505
+ * Download a single receipt as a PDF (privacy center).
506
+ * Returns raw PDF bytes as a Blob — the caller builds an object URL to open/save it.
507
+ * Uses a direct fetch (not privacyCenterApiFetch) because the response is binary, not JSON,
508
+ * and the Authorization header must be sent (anchor navigation wouldn't carry it).
509
+ */
510
+ async getReceiptPdf(
511
+ baseRequest: BaseRequest,
512
+ receiptUuid: string,
513
+ authToken?: string
514
+ ): Promise<Blob> {
515
+ const url = privacyCenterUrl(baseRequest, `/receipts/${receiptUuid}/pdf`);
516
+ const token = resolveAuthToken(authToken, baseRequest);
517
+ const requestHeaders = new Headers();
518
+ requestHeaders.set("Accept", "application/pdf");
519
+ if (token) {
520
+ const trimmedToken = token.trim();
521
+ requestHeaders.set(
522
+ "Authorization",
523
+ trimmedToken.startsWith("Bearer ") ? trimmedToken : `Bearer ${trimmedToken}`
524
+ );
525
+ }
526
+ const response = await fetch(url, { method: "GET", headers: requestHeaders });
527
+ if (!response.ok) {
528
+ let errorMessage = `Request failed with status ${response.status}`;
529
+ try {
530
+ const errorData = (await response.json()) as { detail?: unknown; error_code?: string } | null;
531
+ if (errorData?.detail) {
532
+ errorMessage =
533
+ typeof errorData.detail === "string" ? errorData.detail : JSON.stringify(errorData.detail);
534
+ } else if (errorData?.error_code) {
535
+ errorMessage = errorData.error_code;
536
+ }
537
+ } catch {
538
+ // non-JSON error body — keep default message
539
+ }
540
+ // Prepend HTTP status so callers can branch on it (e.g. 404 = receipt not found)
541
+ throw new Error(`[${response.status}] ${errorMessage}`);
542
+ }
543
+ return response.blob();
544
+ },
545
+
467
546
  /**
468
547
  * Get case history (privacy center)
469
548
  */
@@ -0,0 +1,222 @@
1
+ import { type CSSProperties, useEffect } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PCEmpty } from '../../ui/PCEmpty';
4
+ import { pcStyles } from '../../styles/pcStyles';
5
+ import type { FormProductGroup } from '../../lib/types';
6
+ import { buildGroupPurposes } from '../groupHelpers';
7
+
8
+ const getVar = (varName: string): string => `var(${varName})`;
9
+
10
+ /**
11
+ * Compute the unique selection key for a product group.
12
+ *
13
+ * Direct (own-data) groups key on `product_uuid`. Nominated groups append
14
+ * the nominator UUID so two chips on the same product — one direct and one
15
+ * via a nominator — register as distinct selections.
16
+ */
17
+ export const productGroupKey = (group: FormProductGroup): string =>
18
+ group.nominator
19
+ ? `${group.product_uuid}:${group.nominator.uuid}`
20
+ : group.product_uuid;
21
+
22
+ export type ProductSelectionSectionProps = Readonly<{
23
+ directGroups: FormProductGroup[];
24
+ nominatedGroups: FormProductGroup[];
25
+ selectedProductKey: string | null;
26
+ onSelect: (key: string | null, group: FormProductGroup | null) => void;
27
+ /** Optional human label of the request type, e.g. "access". Folded into
28
+ * the instruction header when supplied. */
29
+ requestTypeLabel?: string;
30
+ isTranslating?: boolean;
31
+ }>;
32
+
33
+ type ChipProps = Readonly<{
34
+ group: FormProductGroup;
35
+ isSelected: boolean;
36
+ isLocked: boolean;
37
+ onSelect: (key: string | null, group: FormProductGroup | null) => void;
38
+ nominationSuffix?: string;
39
+ activePurposesLabel: string;
40
+ }>;
41
+
42
+ const productSelectHeaderStyle: CSSProperties = {
43
+ paddingLeft: pcStyles.pxMd.paddingLeft,
44
+ paddingRight: pcStyles.pxMd.paddingRight,
45
+ marginBottom: pcStyles.mbMd.marginBottom,
46
+ };
47
+
48
+ const ProductChip = ({
49
+ group,
50
+ isSelected,
51
+ isLocked,
52
+ onSelect,
53
+ nominationSuffix,
54
+ activePurposesLabel,
55
+ }: ChipProps) => {
56
+ const key = productGroupKey(group);
57
+
58
+ return (
59
+ <button
60
+ type="button"
61
+ role="option"
62
+ aria-selected={isSelected}
63
+ disabled={isLocked}
64
+ title={group.product_name}
65
+ className={`pc-product-select-chip ${isSelected ? 'pc-product-select-chip--selected' : ''}`}
66
+ data-testid={`product-chip-${key}`}
67
+ onClick={() => {
68
+ if (isLocked) {
69
+ return;
70
+ }
71
+ if (isSelected) {
72
+ onSelect(null, null);
73
+ } else {
74
+ onSelect(key, group);
75
+ }
76
+ }}
77
+ >
78
+ <span className="pc-product-select-chip-inner">
79
+ <span className="pc-product-select-chip-label">{group.product_name}</span>
80
+ {nominationSuffix && (
81
+ <span className="pc-product-select-chip-suffix">{nominationSuffix}</span>
82
+ )}
83
+ <span className="pc-product-select-chip-meta">{activePurposesLabel}</span>
84
+ </span>
85
+ </button>
86
+ );
87
+ };
88
+
89
+ const SkeletonChip = () => (
90
+ <div
91
+ className="pc-product-select-chip pc-product-select-chip--skeleton"
92
+ aria-hidden="true"
93
+ >
94
+ <div
95
+ style={{
96
+ height: '0.875rem',
97
+ width: '6rem',
98
+ animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
99
+ borderRadius: pcStyles.borderRadiusMd,
100
+ backgroundColor: getVar('--pc-bg-tertiary'),
101
+ }}
102
+ />
103
+ </div>
104
+ );
105
+
106
+ export function ProductSelectionSection({
107
+ directGroups,
108
+ nominatedGroups,
109
+ selectedProductKey,
110
+ onSelect,
111
+ requestTypeLabel,
112
+ isTranslating = false,
113
+ }: ProductSelectionSectionProps) {
114
+ const { t } = useTranslation();
115
+
116
+ const totalGroups = directGroups.length + nominatedGroups.length;
117
+ const isSingleGroup = totalGroups === 1;
118
+ const singleGroup = isSingleGroup
119
+ ? (directGroups[0] ?? nominatedGroups[0] ?? null)
120
+ : null;
121
+
122
+ // Auto-select the only group when present. This drives the parent's
123
+ // selection state without requiring a click — per spec, single-group
124
+ // step is pre-selected and rendered read-only.
125
+ useEffect(() => {
126
+ if (!singleGroup) {
127
+ return;
128
+ }
129
+ const key = productGroupKey(singleGroup);
130
+ if (selectedProductKey !== key) {
131
+ onSelect(key, singleGroup);
132
+ }
133
+ }, [singleGroup, selectedProductKey, onSelect]);
134
+
135
+ if (isTranslating) {
136
+ return (
137
+ <div className="pc-product-select-section">
138
+ <div
139
+ className="pc-product-select-chips"
140
+ data-testid="product-selection-chips"
141
+ role="listbox"
142
+ aria-label={t('selectProductsForRequestGeneric')}
143
+ >
144
+ {[0, 1, 2].map((i) => (
145
+ <SkeletonChip key={i} />
146
+ ))}
147
+ </div>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ if (totalGroups === 0) {
153
+ return (
154
+ <PCEmpty
155
+ title={t('noProductsAvailable')}
156
+ description={t('noProductsAvailableDescription')}
157
+ />
158
+ );
159
+ }
160
+
161
+ const headerText = requestTypeLabel
162
+ ? t('selectProductsForRequest', { requestType: requestTypeLabel })
163
+ : t('selectProductsForRequestGeneric');
164
+
165
+ return (
166
+ <div className="pc-product-select-section">
167
+ <div style={productSelectHeaderStyle} className="pc-product-select-header">
168
+ <p
169
+ style={{
170
+ margin: 0,
171
+ fontSize: pcStyles.textXs.fontSize,
172
+ color: getVar('--pc-text-secondary'),
173
+ }}
174
+ >
175
+ {headerText}
176
+ </p>
177
+ </div>
178
+
179
+ <div
180
+ className="pc-product-select-chips"
181
+ data-testid="product-selection-chips"
182
+ role="listbox"
183
+ aria-label={headerText}
184
+ >
185
+ {directGroups.map((group) => {
186
+ const key = productGroupKey(group);
187
+ const purposeCount = buildGroupPurposes(group).length;
188
+ return (
189
+ <ProductChip
190
+ key={key}
191
+ group={group}
192
+ isSelected={selectedProductKey === key || isSingleGroup}
193
+ isLocked={isSingleGroup}
194
+ onSelect={onSelect}
195
+ activePurposesLabel={t('activePurposes', { count: purposeCount })}
196
+ />
197
+ );
198
+ })}
199
+
200
+ {nominatedGroups.map((group) => {
201
+ const key = productGroupKey(group);
202
+ const purposeCount = buildGroupPurposes(group).length;
203
+ const nominatorLabel =
204
+ group.nominator?.name ?? group.nominator?.email ?? '';
205
+ return (
206
+ <ProductChip
207
+ key={key}
208
+ group={group}
209
+ isSelected={selectedProductKey === key || isSingleGroup}
210
+ isLocked={isSingleGroup}
211
+ onSelect={onSelect}
212
+ nominationSuffix={t('nominationChipSuffix', {
213
+ nominator: nominatorLabel,
214
+ })}
215
+ activePurposesLabel={t('activePurposes', { count: purposeCount })}
216
+ />
217
+ );
218
+ })}
219
+ </div>
220
+ </div>
221
+ );
222
+ }