@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,24 @@
1
+ // Internal types for RedactoNoticeAssisted.
2
+
3
+ /** The three screens of the agent-assisted consent flow. */
4
+ export type ConsoleState = "notice" | "otp" | "confirmed";
5
+
6
+ /**
7
+ * Public props. The notice is fetched from the PUBLIC notice endpoint, so the
8
+ * organisation / workspace / notice UUIDs and base URL are supplied by the host
9
+ * (typically read from the frontend URL) — there is no auth token.
10
+ */
11
+ export type RedactoNoticeAssistedProps = {
12
+ /** Organisation UUID (path segment of the public notice endpoint). */
13
+ organisationUuid: string;
14
+ /** Workspace UUID (path segment of the public notice endpoint). */
15
+ workspaceUuid: string;
16
+ /** Notice UUID to fetch and render. */
17
+ noticeUuid: string;
18
+ /** API base URL (defaults to the production consent API). */
19
+ baseUrl?: string;
20
+ /** Called when the agent/customer declines (host should unmount the modal). */
21
+ onDecline?: () => void;
22
+ /** Called when the agent clicks "Proceed" on the confirmed screen. */
23
+ onComplete?: () => void;
24
+ };
@@ -0,0 +1,36 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ export const useMediaQuery = (query: string): boolean => {
4
+ const getMatches = () =>
5
+ typeof window !== "undefined" ? window.matchMedia(query).matches : false;
6
+ const [matches, setMatches] = useState<boolean>(getMatches);
7
+
8
+ useEffect(() => {
9
+ if (typeof window === "undefined") return;
10
+ const media = window.matchMedia(query);
11
+
12
+ // Create event listener
13
+ const listener = (e: MediaQueryListEvent) => {
14
+ setMatches(e.matches);
15
+ };
16
+
17
+ // Add listener (fallback for legacy browsers)
18
+ if (media.addEventListener) {
19
+ media.addEventListener("change", listener);
20
+ } else {
21
+ // @ts-ignore – deprecated fallback
22
+ media.addListener(listener);
23
+ }
24
+
25
+ // Clean up
26
+ return () => {
27
+ if (media.removeEventListener) {
28
+ media.removeEventListener("change", listener);
29
+ } else {
30
+ media.removeListener(listener);
31
+ }
32
+ };
33
+ }, [query]);
34
+
35
+ return matches;
36
+ };
@@ -3,6 +3,7 @@ import { IoChevronBackOutline } from 'react-icons/io5';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { usePageChrome } from './context/PageChromeContext';
5
5
  import { fetchNotifications } from './lib/api';
6
+ import { isRefreshBlocked } from './lib/utils';
6
7
  import type { Notification, NotificationSummary, NotificationCTA, FilterTab } from './lib/types';
7
8
  import { TAB_TO_CATEGORY, POLL_INTERVAL, NOTIFICATION_PAGE_SIZE, useNotificationStatLabels } from './lib/constants';
8
9
  import { useCurrentLanguage } from './lib/useCurrentLanguage';
@@ -39,6 +40,13 @@ export const NotificationsPage = () => {
39
40
  if (!organizationId || !workspaceId) {
40
41
  return;
41
42
  }
43
+ // Skip polling/loading work while auth is terminally failed / backing off
44
+ // (incident 2026-06-22); clear spinners so the UI doesn't hang.
45
+ if (isRefreshBlocked(authContext)) {
46
+ setIsLoading(false);
47
+ setIsRefreshing(false);
48
+ return;
49
+ }
42
50
  if (!silent) {
43
51
  setIsLoading(true);
44
52
  }
@@ -1,4 +1,5 @@
1
1
  import type { BaseRequest } from './client';
2
+ import { isTerminalRefreshError } from './fetcher';
2
3
 
3
4
  export type ConsentApiErrorCode = 'NOMINATOR_NOT_FOUND' | 'NOMINEE_MUST_SPECIFY_NOMINATOR';
4
5
 
@@ -219,20 +220,14 @@ export async function refreshOtpTokenServer(
219
220
  }
220
221
  return response;
221
222
  } catch (error: any) {
222
- // Check if this is an expired refresh token error (expected scenario)
223
- const errorMessage = error?.message || '';
224
- const isExpiredTokenError =
225
- errorMessage.includes('expired') ||
226
- errorMessage.includes('Invalid refresh token') ||
227
- errorMessage.includes('Refresh token') ||
228
- errorMessage.includes('revoked');
229
-
230
- // For expired/revoked tokens, return null gracefully (don't log as error)
231
- if (isExpiredTokenError) {
223
+ // Terminal token errors (expired / not found / already used / 4xx) are an
224
+ // expected outcome — return null so the caller trips the circuit breaker
225
+ // instead of treating it as a retryable exception (incident 2026-06-22).
226
+ if (isTerminalRefreshError(error)) {
232
227
  return null;
233
228
  }
234
229
 
235
- // For other errors (network, etc.), log and re-throw
230
+ // Transient errors (network / 5xx): re-throw so the caller can back off + retry.
236
231
  console.error('Error refreshing token:', error);
237
232
  throw error;
238
233
  }
@@ -1,4 +1,4 @@
1
- import { apiFetch } from "./fetcher";
1
+ import { apiFetch, ApiError } from "./fetcher";
2
2
  import type { ApiFetchOptions, ApiResponse } from "./fetcher";
3
3
  import type {
4
4
  CaseMessage,
@@ -86,6 +86,8 @@ export function createConsentClient(baseUrl: string) {
86
86
  // Handle non-OK responses
87
87
  if (!response.ok) {
88
88
  let errorMessage = `Request failed with status ${response.status}`;
89
+ let errorCode: string | undefined;
90
+ let errorDetail: unknown;
89
91
 
90
92
  try {
91
93
  const errorData = (await response.json()) as {
@@ -94,6 +96,10 @@ export function createConsentClient(baseUrl: string) {
94
96
  code?: string;
95
97
  } | null;
96
98
  if (errorData) {
99
+ errorDetail = errorData.detail;
100
+ if (typeof errorData.code === "string") {
101
+ errorCode = errorData.code;
102
+ }
97
103
  if ("detail" in errorData && errorData.detail) {
98
104
  errorMessage =
99
105
  typeof errorData.detail === "string"
@@ -118,8 +124,14 @@ export function createConsentClient(baseUrl: string) {
118
124
  }
119
125
  }
120
126
 
121
- // Prepend HTTP status so callers can branch on it without losing context
122
- throw new Error(`[${response.status}] ${errorMessage}`);
127
+ // Throw a typed error carrying the status/code. The `[status]` prefix is
128
+ // kept in the message for back-compat with callers that branch on it
129
+ // (e.g. getFormData checks for "404").
130
+ throw new ApiError(`[${response.status}] ${errorMessage}`, {
131
+ status: response.status,
132
+ code: errorCode,
133
+ detail: errorDetail,
134
+ });
123
135
  }
124
136
 
125
137
  // Parse JSON response
@@ -17,6 +17,72 @@ export type ApiResponse<T> = {
17
17
  detail: T;
18
18
  };
19
19
 
20
+ /**
21
+ * Error thrown by the fetch wrappers that preserves the HTTP status code and any
22
+ * server-provided error code alongside the human-readable message.
23
+ *
24
+ * This exists so token-refresh classification (and react-query retry guards) can
25
+ * reliably distinguish a *terminal* 4xx (the refresh token is dead — retrying just
26
+ * hammers the server) from a *transient* 5xx/network failure, without resorting to
27
+ * brittle substring matching on localized message strings. See the production
28
+ * `/otp/refresh` retry storm on 2026-06-22.
29
+ */
30
+ export class ApiError extends Error {
31
+ readonly status?: number;
32
+ readonly code?: string;
33
+ readonly detail?: unknown;
34
+
35
+ constructor(
36
+ message: string,
37
+ options: { status?: number; code?: string; detail?: unknown } = {}
38
+ ) {
39
+ super(message);
40
+ this.name = "ApiError";
41
+ this.status = options.status;
42
+ this.code = options.code;
43
+ this.detail = options.detail;
44
+ // Restore the prototype chain so `instanceof ApiError` works after the
45
+ // class is transpiled down (tsup → ES targets).
46
+ Object.setPrototypeOf(this, ApiError.prototype);
47
+ }
48
+ }
49
+
50
+ // HTTP statuses that mean "this refresh token will never succeed" — retrying is
51
+ // pointless and only adds server load. 5xx / 408 / 429 / network errors are NOT
52
+ // in this set: those are transient and handled with backoff instead.
53
+ const TERMINAL_REFRESH_STATUSES = new Set<number>([400, 401, 403, 422]);
54
+
55
+ /**
56
+ * True when an error from the OTP refresh endpoint is terminal — i.e. the refresh
57
+ * token is expired, not found, already used, or otherwise invalid, so no amount of
58
+ * immediate retrying will help. Prefers the HTTP status carried by {@link ApiError};
59
+ * falls back to a deliberately narrow message match for non-typed throws.
60
+ */
61
+ export function isTerminalRefreshError(error: unknown): boolean {
62
+ const e = error as { status?: unknown; message?: unknown } | null | undefined;
63
+ if (!e) return false;
64
+
65
+ // Primary, reliable signal: the HTTP status from the refresh response.
66
+ if (typeof e.status === "number") {
67
+ return TERMINAL_REFRESH_STATUSES.has(e.status);
68
+ }
69
+
70
+ // Fallback for errors without a status (older shapes / plain Error). Match only
71
+ // the known terminal token phrases so transient failures aren't misclassified.
72
+ const message = (typeof e.message === "string" ? e.message : "").toLowerCase();
73
+ if (!message) return false;
74
+ return (
75
+ message.includes("expired") ||
76
+ message.includes("revoked") ||
77
+ message.includes("not found") ||
78
+ message.includes("already used") ||
79
+ message.includes("invalid refresh token") ||
80
+ message.includes("unauthorized") ||
81
+ message.includes("[401]") ||
82
+ message.includes("[403]")
83
+ );
84
+ }
85
+
20
86
  /**
21
87
  * Unified fetch wrapper for client-side API calls.
22
88
  * Provides typed responses and automatic error handling.
@@ -84,13 +150,20 @@ export async function apiFetch<T>(
84
150
  // Handle non-OK responses
85
151
  if (!response.ok) {
86
152
  let errorMessage = `Request failed with status ${response.status}`;
153
+ let errorCode: string | undefined;
154
+ let errorDetail: unknown;
87
155
 
88
156
  try {
89
157
  const errorData = (await response.json()) as {
90
158
  detail?: unknown;
91
159
  message?: string;
160
+ code?: string;
92
161
  } | null;
93
162
  if (errorData) {
163
+ errorDetail = errorData.detail;
164
+ if (typeof errorData.code === "string") {
165
+ errorCode = errorData.code;
166
+ }
94
167
  if ("detail" in errorData && errorData.detail) {
95
168
  errorMessage =
96
169
  typeof errorData.detail === "string"
@@ -112,7 +185,13 @@ export async function apiFetch<T>(
112
185
  }
113
186
  }
114
187
 
115
- throw new Error(errorMessage);
188
+ // Preserve status + server error code so callers can classify terminal vs.
189
+ // transient failures without brittle string matching (incident 2026-06-22).
190
+ throw new ApiError(errorMessage, {
191
+ status: response.status,
192
+ code: errorCode,
193
+ detail: errorDetail,
194
+ });
116
195
  }
117
196
 
118
197
  // Handle empty responses (common for DELETE requests with 204 No Content)