@redacto.io/consent-sdk-react 3.4.0 → 3.6.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 (155) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +176 -0
  3. package/dist/index.d.mts +51 -4
  4. package/dist/index.d.ts +51 -4
  5. package/dist/index.js +1912 -345
  6. package/dist/index.mjs +1911 -345
  7. package/dist/privacy-center.d.mts +566 -0
  8. package/dist/privacy-center.d.ts +566 -0
  9. package/dist/privacy-center.js +17174 -0
  10. package/dist/privacy-center.mjs +17136 -0
  11. package/package.json +47 -3
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +201 -118
  13. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +1194 -93
  14. package/src/RedactoNoticeConsent/api/index.ts +337 -246
  15. package/src/RedactoNoticeConsent/api/types.ts +140 -41
  16. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +602 -0
  17. package/src/RedactoNoticeConsent/components/index.ts +7 -0
  18. package/src/RedactoNoticeConsent/injectStyles.ts +11 -0
  19. package/src/RedactoNoticeConsent/language-codes.ts +61 -0
  20. package/src/RedactoNoticeConsent/services/storage.ts +122 -0
  21. package/src/RedactoNoticeConsent/types.ts +2 -0
  22. package/src/RedactoNoticeConsentInline/api/index.ts +26 -64
  23. package/src/RedactoNoticeConsentInline/api/types.ts +0 -25
  24. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
  25. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
  26. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
  27. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +27 -0
  28. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +142 -0
  29. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +51 -0
  30. package/src/RedactoPrivacyCenter/api/actions.ts +444 -0
  31. package/src/RedactoPrivacyCenter/api/client.ts +413 -0
  32. package/src/RedactoPrivacyCenter/api/config.ts +33 -0
  33. package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
  34. package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
  35. package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
  36. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
  37. package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
  38. package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
  39. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
  40. package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
  41. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
  42. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
  43. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +468 -0
  44. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +433 -0
  45. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +726 -0
  46. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +239 -0
  47. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
  48. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
  49. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
  50. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
  51. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
  52. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +305 -0
  53. package/src/RedactoPrivacyCenter/components/Form.tsx +1455 -0
  54. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +176 -0
  55. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +330 -0
  56. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +329 -0
  57. package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
  58. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +302 -0
  59. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +218 -0
  60. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
  61. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +412 -0
  62. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
  63. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
  64. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
  65. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
  66. package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
  67. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
  68. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +179 -0
  69. package/src/RedactoPrivacyCenter/components/constant.ts +20 -0
  70. package/src/RedactoPrivacyCenter/components/types.ts +68 -0
  71. package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
  72. package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
  73. package/src/RedactoPrivacyCenter/config/table.ts +25 -0
  74. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +123 -0
  75. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +44 -0
  76. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
  77. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +64 -0
  78. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
  79. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
  80. package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
  81. package/src/RedactoPrivacyCenter/lib/api.ts +468 -0
  82. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
  83. package/src/RedactoPrivacyCenter/lib/constants.ts +116 -0
  84. package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
  85. package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
  86. package/src/RedactoPrivacyCenter/lib/queries.ts +30 -0
  87. package/src/RedactoPrivacyCenter/lib/types.ts +346 -0
  88. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
  89. package/src/RedactoPrivacyCenter/lib/utils.ts +625 -0
  90. package/src/RedactoPrivacyCenter/locales/as/translation.json +188 -0
  91. package/src/RedactoPrivacyCenter/locales/bho/translation.json +188 -0
  92. package/src/RedactoPrivacyCenter/locales/bn/translation.json +188 -0
  93. package/src/RedactoPrivacyCenter/locales/brx/translation.json +188 -0
  94. package/src/RedactoPrivacyCenter/locales/doi/translation.json +188 -0
  95. package/src/RedactoPrivacyCenter/locales/en/translation.json +188 -0
  96. package/src/RedactoPrivacyCenter/locales/gom/translation.json +188 -0
  97. package/src/RedactoPrivacyCenter/locales/gu/translation.json +188 -0
  98. package/src/RedactoPrivacyCenter/locales/hi/translation.json +188 -0
  99. package/src/RedactoPrivacyCenter/locales/kn/translation.json +188 -0
  100. package/src/RedactoPrivacyCenter/locales/ks/translation.json +188 -0
  101. package/src/RedactoPrivacyCenter/locales/mai/translation.json +188 -0
  102. package/src/RedactoPrivacyCenter/locales/ml/translation.json +188 -0
  103. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +188 -0
  104. package/src/RedactoPrivacyCenter/locales/mr/translation.json +188 -0
  105. package/src/RedactoPrivacyCenter/locales/ne/translation.json +188 -0
  106. package/src/RedactoPrivacyCenter/locales/or/translation.json +188 -0
  107. package/src/RedactoPrivacyCenter/locales/pa/translation.json +188 -0
  108. package/src/RedactoPrivacyCenter/locales/sa/translation.json +188 -0
  109. package/src/RedactoPrivacyCenter/locales/sat/translation.json +188 -0
  110. package/src/RedactoPrivacyCenter/locales/sd/translation.json +188 -0
  111. package/src/RedactoPrivacyCenter/locales/ta/translation.json +188 -0
  112. package/src/RedactoPrivacyCenter/locales/te/translation.json +188 -0
  113. package/src/RedactoPrivacyCenter/locales/ur/translation.json +188 -0
  114. package/src/RedactoPrivacyCenter/styles/base.css +117 -0
  115. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1000 -0
  116. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +819 -0
  117. package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
  118. package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
  119. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +365 -0
  120. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +479 -0
  121. package/src/RedactoPrivacyCenter/table/index.ts +2 -0
  122. package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
  123. package/src/RedactoPrivacyCenter/ui/Footer.tsx +73 -0
  124. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
  125. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +87 -0
  126. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
  127. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
  128. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
  129. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
  130. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
  131. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +97 -0
  132. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
  133. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +128 -0
  134. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +30 -0
  135. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +104 -0
  136. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +175 -0
  137. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +381 -0
  138. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +137 -0
  139. package/src/RedactoPrivacyCenter/ui/PCText.tsx +39 -0
  140. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
  141. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
  142. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
  143. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
  144. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +55 -0
  145. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +70 -0
  146. package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
  147. package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
  148. package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
  149. package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
  150. package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
  151. package/src/index.ts +10 -0
  152. package/src/privacy-center.ts +107 -0
  153. package/tsconfig.json +2 -0
  154. package/tsup.config.ts +16 -1
  155. package/src/utils/isLegacyApi.ts +0 -12
@@ -0,0 +1,625 @@
1
+ import { refreshOtpTokenServer } from './api';
2
+ import type { BaseUrlContextValue } from '../context/BaseUrlContext';
3
+
4
+ // Token refresh lock to prevent concurrent refresh attempts
5
+ let isRefreshing = false;
6
+ let refreshPromise: Promise<string | null> | null = null;
7
+
8
+ // Buffer time in seconds before token expiration to proactively refresh
9
+ // Using 30 seconds to ensure tokens are refreshed before they expire
10
+ const TOKEN_EXPIRY_BUFFER_SECONDS = 30;
11
+
12
+ export const getCookieValue = (name: string) => {
13
+ const cookies = document.cookie.split('; ');
14
+ for (let cookie of cookies) {
15
+ const [key, value] = cookie.split('=');
16
+ if (key === name) {
17
+ return value;
18
+ }
19
+ }
20
+ return null;
21
+ };
22
+
23
+ /**
24
+ * Set refresh token cookie with proper attributes
25
+ * @param refreshToken - The refresh token value to set
26
+ */
27
+ export const setRefreshTokenCookie = (refreshToken: string): void => {
28
+ if (typeof window === 'undefined') {
29
+ return;
30
+ }
31
+
32
+ const cookieAttributes = [
33
+ `refresh_token=${refreshToken}`,
34
+ 'path=/privacy-center',
35
+ `max-age=${60 * 60 * 24 * 7}`, // 7 days
36
+ `samesite=lax`,
37
+ ]
38
+ .filter(Boolean)
39
+ .join('; ');
40
+
41
+ document.cookie = cookieAttributes;
42
+ };
43
+
44
+ /**
45
+ * Decode JWT token and extract payload
46
+ * @param token - JWT token string
47
+ * @returns Decoded JWT payload or null if decoding fails
48
+ */
49
+ export const decodeJwtToken = (token: string): { organisation_uuid?: string; organisation_name?: string; workspace_uuid?: string; [key: string]: any } | null => {
50
+ if (!token || typeof token !== 'string') {
51
+ return null;
52
+ }
53
+
54
+ try {
55
+ // JWT format: header.payload.signature
56
+ const parts = token.split('.');
57
+ if (parts.length !== 3) {
58
+ // Malformed JWT
59
+ return null;
60
+ }
61
+
62
+ // Decode payload (second part)
63
+ const payload = parts[1];
64
+
65
+ // Base64URL decode
66
+ // Replace URL-safe characters and add padding if needed
67
+ let base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
68
+ const padding = base64.length % 4;
69
+ if (padding) {
70
+ base64 += '='.repeat(4 - padding);
71
+ }
72
+
73
+ // Decode base64
74
+ const decodedPayload = atob(base64);
75
+ const payloadObj = JSON.parse(decodedPayload) as { organisation_uuid?: string; organisation_name?: string; workspace_uuid?: string; [key: string]: any };
76
+
77
+ return payloadObj;
78
+ } catch (error) {
79
+ // If decoding fails, return null
80
+ console.error('Error decoding JWT:', error);
81
+ return null;
82
+ }
83
+ };
84
+
85
+ /**
86
+ * Decode JWT payload and check if token is expired or will expire soon
87
+ * @param token - JWT token string
88
+ * @param bufferSeconds - Buffer time in seconds before expiration to consider token expired (default: 5 minutes)
89
+ * @returns true if token is expired, invalid, or will expire within buffer time; false if valid
90
+ */
91
+ export const isJwtExpired = (token: string, bufferSeconds: number = 5 * 60): boolean => {
92
+ if (!token || typeof token !== 'string') {
93
+ return true;
94
+ }
95
+
96
+ try {
97
+ // JWT format: header.payload.signature
98
+ const parts = token.split('.');
99
+ if (parts.length !== 3) {
100
+ // Malformed JWT
101
+ return true;
102
+ }
103
+
104
+ // Decode payload (second part)
105
+ const payload = parts[1];
106
+
107
+ // Base64URL decode
108
+ // Replace URL-safe characters and add padding if needed
109
+ let base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
110
+ const padding = base64.length % 4;
111
+ if (padding) {
112
+ base64 += '='.repeat(4 - padding);
113
+ }
114
+
115
+ // Decode base64
116
+ const decodedPayload = atob(base64);
117
+ const payloadObj: { exp?: number } = JSON.parse(decodedPayload) as { exp?: number };
118
+
119
+ // Check if exp claim exists
120
+ if (typeof payloadObj.exp !== 'number') {
121
+ // Missing exp claim - treat as invalid
122
+ return true;
123
+ }
124
+
125
+ // Get current time in seconds
126
+ const now = Math.floor(Date.now() / 1000);
127
+
128
+ // Check if token is expired or will expire within buffer time
129
+ const isExpired = payloadObj.exp <= (now + bufferSeconds);
130
+
131
+ // exp is in seconds since epoch
132
+ return isExpired;
133
+ } catch (error) {
134
+ // If decoding fails, treat as expired/invalid
135
+ console.error('Error decoding JWT:', error);
136
+ return true;
137
+ }
138
+ };
139
+
140
+ /**
141
+ * Check if user is authenticated in privacy center
142
+ * User is considered authenticated if both access_token and email exist in sessionStorage
143
+ */
144
+ export const isUserAuthenticated = (expectedSlug: string): boolean => {
145
+ if (typeof window === 'undefined') return false;
146
+ const accessToken = sessionStorage.getItem('access_token');
147
+ const email = sessionStorage.getItem('email');
148
+ const orgSlug = sessionStorage.getItem('pc_org_slug');
149
+
150
+ // Require that tokens exist and the stored organization slug matches
151
+ if (!accessToken || !email || !orgSlug) {
152
+ return false;
153
+ }
154
+
155
+ return orgSlug === expectedSlug;
156
+ };
157
+
158
+ /**
159
+ * Get initial state for sign-in form visibility
160
+ * Returns true if window is undefined (SSR) or user is not authenticated
161
+ * @param slug - The organization slug to check authentication against
162
+ * @returns true if sign-in form should be shown, false otherwise
163
+ */
164
+ export const getInitialState = (slug: string): boolean => {
165
+ if (typeof window === 'undefined') return true;
166
+ return !isUserAuthenticated(slug);
167
+ };
168
+
169
+ /**
170
+ * Check authentication status and attempt token refresh if needed
171
+ * This function checks if the user is authenticated, validates JWT expiration,
172
+ * and attempts to refresh using the refresh token cookie if the token is expired or missing.
173
+ * @param expectedSlug - The organization slug to verify against
174
+ * @param organizationId - Optional organization ID (will be read from sessionStorage if not provided)
175
+ * @param workspaceId - Optional workspace ID (will be read from sessionStorage if not provided)
176
+ * @returns Promise<boolean> - true if authenticated with valid non-expired token, false otherwise
177
+ */
178
+ export const checkAndRefreshAuth = async (
179
+ expectedSlug: string,
180
+ organizationId?: string,
181
+ workspaceId?: string,
182
+ consentClient?: BaseUrlContextValue['consentClient']
183
+ ): Promise<boolean> => {
184
+ if (typeof window === 'undefined') return false;
185
+
186
+ const accessToken = sessionStorage.getItem('access_token');
187
+ const email = sessionStorage.getItem('email');
188
+ const orgSlug = sessionStorage.getItem('pc_org_slug');
189
+
190
+ // If slug doesn't match, user is not authenticated for this organization
191
+ if (orgSlug && orgSlug !== expectedSlug) {
192
+ return false;
193
+ }
194
+
195
+ // Check if we need to refresh (token missing or expired)
196
+ const needsRefresh = !accessToken || (accessToken && isJwtExpired(accessToken, TOKEN_EXPIRY_BUFFER_SECONDS));
197
+
198
+ // If we have a valid token and all required data, user is authenticated
199
+ if (!needsRefresh && accessToken && email && orgSlug && orgSlug === expectedSlug) {
200
+ return true;
201
+ }
202
+
203
+ // If token is missing or expired, and we have email and orgSlug, try to refresh
204
+ if (needsRefresh && email && orgSlug === expectedSlug) {
205
+ // Get organizationId and workspaceId from sessionStorage if not provided
206
+ const orgId = organizationId || sessionStorage.getItem('pc_org_id') || '';
207
+ const wsId = workspaceId || sessionStorage.getItem('pc_workspace_id') || '';
208
+
209
+ if (!orgId || !wsId) {
210
+ return false;
211
+ }
212
+
213
+ try {
214
+ const refreshToken = getCookieValue('refresh_token');
215
+ if (!refreshToken) {
216
+ // No refresh token available - user needs to sign in again
217
+ signOutPrivacyCenter();
218
+ return false;
219
+ }
220
+
221
+ const result = await refreshOtpTokenServer(orgId, wsId, refreshToken, consentClient);
222
+ if (result && result.success !== false) {
223
+ const newAccessToken = (result as { access_token?: string })?.access_token;
224
+ const newRefreshToken = (result as { refresh_token?: string })?.refresh_token;
225
+
226
+ if (newAccessToken) {
227
+ // Validate the new token is not expired before storing
228
+ if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
229
+ console.error('Refreshed access token is already expired - clearing auth state');
230
+ signOutPrivacyCenter();
231
+ return false;
232
+ }
233
+ sessionStorage.setItem('access_token', newAccessToken);
234
+ } else {
235
+ // No access token in response - refresh failed
236
+ console.error('Refresh response missing access token - clearing auth state');
237
+ signOutPrivacyCenter();
238
+ return false;
239
+ }
240
+
241
+ if (newRefreshToken) {
242
+ setRefreshTokenCookie(newRefreshToken);
243
+ }
244
+
245
+ // Verify we now have a valid, non-expired access token
246
+ const isValid = !!newAccessToken && !isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS);
247
+ if (!isValid) {
248
+ console.error('Refreshed token validation failed - clearing auth state');
249
+ signOutPrivacyCenter();
250
+ }
251
+ return isValid;
252
+ } else {
253
+ // Refresh token expired/revoked or server returned failure
254
+ // This is an expected scenario - silently handle it
255
+ signOutPrivacyCenter();
256
+ return false;
257
+ }
258
+ } catch (error: any) {
259
+ // Check if error indicates refresh token is expired/revoked (expected scenario)
260
+ const errorMessage = error?.message || '';
261
+ const isRefreshTokenExpired =
262
+ errorMessage.includes('revoked') ||
263
+ errorMessage.includes('expired') ||
264
+ errorMessage.includes('Refresh token') ||
265
+ errorMessage.includes('Invalid refresh token') ||
266
+ errorMessage.includes('401') ||
267
+ errorMessage.includes('Unauthorized');
268
+
269
+ if (isRefreshTokenExpired) {
270
+ // Refresh token is expired/revoked - silently handle (expected scenario)
271
+ signOutPrivacyCenter();
272
+ return false;
273
+ }
274
+
275
+ // For other errors (network, etc.), log and don't clear auth state - might be temporary
276
+ console.error('[Token Refresh] Unexpected error refreshing token:', error);
277
+ return false;
278
+ }
279
+ }
280
+
281
+ return false;
282
+ };
283
+
284
+ /**
285
+ * Compare two email addresses (case-insensitive, handles URL encoding)
286
+ * @param email1 - First email (can be URL-encoded)
287
+ * @param email2 - Second email
288
+ * @returns true if emails match (case-insensitive), false otherwise
289
+ */
290
+ export const compareEmails = (email1: string | null, email2: string | null): boolean => {
291
+ if (!email1 || !email2) {
292
+ return false;
293
+ }
294
+
295
+ // Decode URL-encoded email (handles %40 for @, %2B for +, etc.)
296
+ const decodedEmail1 = decodeURIComponent(email1.trim());
297
+ const normalizedEmail1 = decodedEmail1.toLowerCase();
298
+ const normalizedEmail2 = email2.trim().toLowerCase();
299
+
300
+ return normalizedEmail1 === normalizedEmail2;
301
+ };
302
+
303
+ /**
304
+ * Sign out from privacy center only
305
+ * Clears privacy-center specific storage and cookies without touching Supabase auth
306
+ */
307
+ export const signOutPrivacyCenter = (): void => {
308
+ if (typeof window === 'undefined') return;
309
+
310
+ const privacyCenterKeys = [
311
+ 'access_token',
312
+ 'email',
313
+ 'i18nextLng',
314
+ 'lang',
315
+ 'pc_org_slug',
316
+ 'pc_org_id',
317
+ 'pc_workspace_id',
318
+ 'pc_current_page',
319
+ 'pc_grievance_view',
320
+ ];
321
+ privacyCenterKeys.forEach((key) => {
322
+ sessionStorage.removeItem(key);
323
+ });
324
+
325
+ // Clear refresh token cookie set for privacy center
326
+ document.cookie =
327
+ 'refresh_token=; path=/privacy-center; expires=Thu, 01 Jan 1970 00:00:00 GMT';
328
+ // Fallback in case the cookie path differs
329
+ document.cookie =
330
+ 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
331
+ };
332
+
333
+ /**
334
+ * Ensure a valid access token is available before making API calls.
335
+ * This function checks if the current token is valid (not expired).
336
+ * If expired or missing, it attempts to refresh using the refresh token.
337
+ * If refresh fails (refresh token expired), it signs out and redirects to sign-in.
338
+ *
339
+ * @param organizationId - The organization UUID
340
+ * @param workspaceId - The workspace UUID
341
+ * @param slug - The organization slug for redirect
342
+ * @param authContext - Optional auth context values (accessToken, refreshToken, onError, updateTokens)
343
+ * @returns Promise<string | null> - Valid access token or null if auth failed
344
+ */
345
+ export const ensureValidToken = async (
346
+ organizationId?: string,
347
+ workspaceId?: string,
348
+ slug?: string,
349
+ authContext?: {
350
+ accessToken?: string;
351
+ refreshToken?: string;
352
+ onError?: (error: Error) => boolean;
353
+ updateTokens?: (accessToken: string, refreshToken?: string) => void;
354
+ },
355
+ consentClient?: BaseUrlContextValue['consentClient']
356
+ ): Promise<string | null> => {
357
+ if (typeof window === 'undefined') return null;
358
+
359
+ // Use context accessToken if provided, otherwise fall back to sessionStorage
360
+ const accessToken = authContext?.accessToken || sessionStorage.getItem('access_token');
361
+
362
+ // Check if current token is valid (exists, is a string, and not expired)
363
+ // Use a buffer to proactively refresh before actual expiry
364
+ if (
365
+ accessToken &&
366
+ typeof accessToken === 'string' &&
367
+ accessToken.trim() !== '' &&
368
+ !isJwtExpired(accessToken, TOKEN_EXPIRY_BUFFER_SECONDS)
369
+ ) {
370
+ return accessToken;
371
+ }
372
+
373
+ // Token is missing or expired, need to refresh
374
+ // Use lock to prevent concurrent refresh attempts
375
+ if (isRefreshing && refreshPromise) {
376
+ // Wait for ongoing refresh to complete
377
+ const newToken = await refreshPromise;
378
+ // Return the new token directly (it's already stored in sessionStorage/context)
379
+ return newToken;
380
+ }
381
+
382
+ // Start refresh process
383
+ isRefreshing = true;
384
+ refreshPromise = performTokenRefresh(organizationId, workspaceId, slug, authContext, consentClient);
385
+
386
+ try {
387
+ const newToken = await refreshPromise;
388
+ // Return the new token directly (it's already stored in sessionStorage/context)
389
+ return newToken;
390
+ } finally {
391
+ isRefreshing = false;
392
+ refreshPromise = null;
393
+ }
394
+ };
395
+
396
+ /**
397
+ * Internal function to perform token refresh
398
+ * @returns Promise<string | null> - The new access token on success, null on failure
399
+ */
400
+ const performTokenRefresh = async (
401
+ organizationId?: string,
402
+ workspaceId?: string,
403
+ slug?: string,
404
+ authContext?: {
405
+ accessToken?: string;
406
+ refreshToken?: string;
407
+ onError?: (error: Error) => boolean;
408
+ updateTokens?: (accessToken: string, refreshToken?: string) => void;
409
+ },
410
+ consentClient?: BaseUrlContextValue['consentClient']
411
+ ): Promise<string | null> => {
412
+ // Get organizationId and workspaceId from sessionStorage if not provided
413
+ const orgId = organizationId || sessionStorage.getItem('pc_org_id') || '';
414
+ const wsId = workspaceId || sessionStorage.getItem('pc_workspace_id') || '';
415
+ const orgSlug = slug || sessionStorage.getItem('pc_org_slug') || '';
416
+
417
+ if (!orgId || !wsId) {
418
+ console.error('[Token Refresh] Missing organizationId or workspaceId');
419
+ const error = new Error('Missing organizationId or workspaceId');
420
+ // Call onError if provided, check return value before proceeding
421
+ if (authContext?.onError) {
422
+ const shouldProceed = authContext.onError(error);
423
+ if (!shouldProceed) {
424
+ return null;
425
+ }
426
+ }
427
+ signOutPrivacyCenter();
428
+ redirectToSignIn(orgSlug, authContext?.onError);
429
+ return null;
430
+ }
431
+
432
+ // Use context refreshToken if provided, otherwise fall back to cookie
433
+ // If authContext is provided, prioritize its refreshToken (props mode)
434
+ // Otherwise, fall back to cookie (legacy mode)
435
+ let refreshTokenValue: string | null = null;
436
+
437
+ if (authContext) {
438
+ // Props mode: use refreshToken from context
439
+ refreshTokenValue = authContext.refreshToken || null;
440
+ } else {
441
+ // Legacy mode: use refreshToken from cookie
442
+ refreshTokenValue = getCookieValue('refresh_token');
443
+ }
444
+
445
+ // Validate refreshToken is a non-empty string
446
+ if (!refreshTokenValue || typeof refreshTokenValue !== 'string' || refreshTokenValue.trim() === '') {
447
+ // No refresh token available - user needs to sign in again
448
+ console.warn('[Token Refresh] No valid refresh token available', {
449
+ hasAuthContext: !!authContext,
450
+ refreshTokenFromContext: authContext?.refreshToken,
451
+ refreshTokenFromCookie: getCookieValue('refresh_token'),
452
+ });
453
+ const error = new Error('No refresh token available');
454
+ // Call onError if provided, check return value before proceeding
455
+ if (authContext?.onError) {
456
+ const shouldProceed = authContext.onError(error);
457
+ if (!shouldProceed) {
458
+ return null;
459
+ }
460
+ }
461
+ signOutPrivacyCenter();
462
+ redirectToSignIn(orgSlug, authContext?.onError);
463
+ return null;
464
+ }
465
+
466
+ // At this point, refreshTokenValue is guaranteed to be a non-empty string
467
+ const refreshToken: string = refreshTokenValue;
468
+
469
+ try {
470
+ const result = await refreshOtpTokenServer(orgId, wsId, refreshToken, consentClient);
471
+
472
+ if (result && result.success !== false) {
473
+ const newAccessToken = (result as { access_token?: string })?.access_token;
474
+ const newRefreshToken = (result as { refresh_token?: string })?.refresh_token;
475
+
476
+ if (newAccessToken) {
477
+ // Validate the new token is not expired before storing
478
+ if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
479
+ console.error('[Token Refresh] New access token is already expired');
480
+ const error = new Error('New access token is already expired');
481
+ // Call onError if provided, check return value before proceeding
482
+ if (authContext?.onError) {
483
+ const shouldProceed = authContext.onError(error);
484
+ if (!shouldProceed) {
485
+ return null;
486
+ }
487
+ }
488
+ signOutPrivacyCenter();
489
+ redirectToSignIn(orgSlug, authContext?.onError);
490
+ return null;
491
+ }
492
+
493
+ // Update tokens via context if available, otherwise use storage
494
+ if (authContext?.updateTokens) {
495
+ authContext.updateTokens(newAccessToken, newRefreshToken);
496
+ } else {
497
+ sessionStorage.setItem('access_token', newAccessToken);
498
+ if (newRefreshToken) {
499
+ setRefreshTokenCookie(newRefreshToken);
500
+ }
501
+ }
502
+
503
+ // Return the new access token directly (already stored in sessionStorage/context)
504
+ return newAccessToken;
505
+ } else {
506
+ // No access token in response - refresh failed
507
+ console.error('[Token Refresh] Response missing access token');
508
+ const error = new Error('Response missing access token');
509
+ // Call onError if provided, check return value before proceeding
510
+ if (authContext?.onError) {
511
+ const shouldProceed = authContext.onError(error);
512
+ if (!shouldProceed) {
513
+ return null;
514
+ }
515
+ }
516
+ signOutPrivacyCenter();
517
+ redirectToSignIn(orgSlug, authContext?.onError);
518
+ return null;
519
+ }
520
+ } else {
521
+ // Refresh token expired/revoked or server returned failure
522
+ const error = new Error('Refresh token expired or revoked');
523
+ // Call onError if provided, check return value before proceeding
524
+ if (authContext?.onError) {
525
+ const shouldProceed = authContext.onError(error);
526
+ if (!shouldProceed) {
527
+ return null;
528
+ }
529
+ }
530
+ signOutPrivacyCenter();
531
+ redirectToSignIn(orgSlug, authContext?.onError);
532
+ return null;
533
+ }
534
+ } catch (error: any) {
535
+ const errorMessage = error?.message || '';
536
+ const isRefreshTokenExpired =
537
+ errorMessage.includes('revoked') ||
538
+ errorMessage.includes('expired') ||
539
+ errorMessage.includes('Refresh token') ||
540
+ errorMessage.includes('Invalid refresh token') ||
541
+ errorMessage.includes('401') ||
542
+ errorMessage.includes('Unauthorized');
543
+
544
+ if (isRefreshTokenExpired) {
545
+ // Refresh token is expired/revoked - expected scenario
546
+ const err = new Error('Refresh token expired or revoked');
547
+ // Call onError if provided, check return value before proceeding
548
+ if (authContext?.onError) {
549
+ const shouldProceed = authContext.onError(err);
550
+ if (!shouldProceed) {
551
+ return null;
552
+ }
553
+ }
554
+ signOutPrivacyCenter();
555
+ redirectToSignIn(orgSlug, authContext?.onError);
556
+ return null;
557
+ }
558
+
559
+ // For other errors (network, etc.), log but don't clear auth state
560
+ console.error('[Token Refresh] Unexpected error:', error);
561
+ // Still call onError for unexpected errors
562
+ if (authContext?.onError) {
563
+ const err = error instanceof Error ? error : new Error(String(error));
564
+ authContext.onError(err);
565
+ }
566
+ return null;
567
+ }
568
+ };
569
+
570
+ /**
571
+ * Helper to redirect to sign-in page
572
+ */
573
+ const redirectToSignIn = (slug?: string, onError?: (error: Error) => boolean): void => {
574
+ if (typeof window === 'undefined') return;
575
+
576
+ // If onError is provided, call it and check return value
577
+ if (onError) {
578
+ const error = new Error('Authentication failed - redirecting to sign-in');
579
+ const shouldProceed = onError(error);
580
+ if (!shouldProceed) {
581
+ // User wants to handle navigation themselves
582
+ return;
583
+ }
584
+ }
585
+
586
+ const orgSlug = slug || sessionStorage.getItem('pc_org_slug') || '';
587
+ if (orgSlug) {
588
+ window.location.href = `/privacy-center/${orgSlug}/sign-in`;
589
+ } else {
590
+ window.location.href = '/privacy-center';
591
+ }
592
+ };
593
+
594
+ // Activity helper functions
595
+
596
+ export type ActivityBadgeStatus =
597
+ | 'success'
598
+ | 'warning'
599
+ | 'error'
600
+ | 'info'
601
+ | 'pending';
602
+
603
+ /**
604
+ * Get badge status based on activity type
605
+ */
606
+ export const getActivityTypeStatus = (
607
+ activityType: string
608
+ ): ActivityBadgeStatus => {
609
+ const type = activityType.toLowerCase();
610
+ if (type.includes('consent') || type.includes('grant')) return 'success';
611
+ if (type.includes('revoke') || type.includes('withdraw')) return 'error';
612
+ if (type.includes('update') || type.includes('modify')) return 'warning';
613
+ if (type.includes('view') || type.includes('access')) return 'info';
614
+ return 'pending';
615
+ };
616
+
617
+ /**
618
+ * Format activity type for display
619
+ */
620
+ export const formatActivityType = (activityType: string): string => {
621
+ return activityType
622
+ .split('_')
623
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
624
+ .join(' ');
625
+ };