@redacto.io/consent-sdk-react 10.0.0-beta.8 → 10.0.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/NOTICE.md +17 -0
- package/README.md +141 -3
- package/dist/chunk-FEGTW73Q.mjs +182 -0
- package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
- package/dist/fonts.d.mts +35 -0
- package/dist/fonts.d.ts +35 -0
- package/dist/fonts.js +183 -0
- package/dist/fonts.mjs +10 -0
- package/dist/index.d.mts +41 -4
- package/dist/index.d.ts +41 -4
- package/dist/index.js +881 -416
- package/dist/index.mjs +502 -253
- package/dist/montserrat.generated-NBTECYRW.mjs +22 -0
- package/dist/privacy-center.d.mts +51 -11
- package/dist/privacy-center.d.ts +51 -11
- package/dist/privacy-center.js +252 -136
- package/dist/privacy-center.mjs +251 -137
- package/dist/types-6Aj7faxL.d.mts +57 -0
- package/dist/types-6Aj7faxL.d.ts +57 -0
- package/package.json +10 -1
- package/.turbo/turbo-build.log +0 -22
- package/.turbo/turbo-test.log +0 -203
- package/.turbo/turbo-verify-pack.log +0 -6
- package/CHANGELOG.md +0 -343
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +0 -934
- package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +0 -2160
- package/src/RedactoNoticeAssisted/api/index.ts +0 -275
- package/src/RedactoNoticeAssisted/api/types.ts +0 -182
- package/src/RedactoNoticeAssisted/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeAssisted/constants.ts +0 -51
- package/src/RedactoNoticeAssisted/i18n.ts +0 -1408
- package/src/RedactoNoticeAssisted/index.ts +0 -1
- package/src/RedactoNoticeAssisted/injectStyles.ts +0 -251
- package/src/RedactoNoticeAssisted/styles.ts +0 -730
- package/src/RedactoNoticeAssisted/types.ts +0 -26
- package/src/RedactoNoticeAssisted/useMediaQuery.ts +0 -36
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +0 -1152
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +0 -4258
- package/src/RedactoNoticeConsent/api/index.ts +0 -512
- package/src/RedactoNoticeConsent/api/types.ts +0 -469
- package/src/RedactoNoticeConsent/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +0 -602
- package/src/RedactoNoticeConsent/components/index.ts +0 -7
- package/src/RedactoNoticeConsent/constants.ts +0 -20
- package/src/RedactoNoticeConsent/index.ts +0 -1
- package/src/RedactoNoticeConsent/injectStyles.ts +0 -151
- package/src/RedactoNoticeConsent/language-codes.ts +0 -95
- package/src/RedactoNoticeConsent/services/storage.ts +0 -122
- package/src/RedactoNoticeConsent/styles.ts +0 -409
- package/src/RedactoNoticeConsent/types.ts +0 -54
- package/src/RedactoNoticeConsent/useMediaQuery.ts +0 -36
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.test.tsx +0 -369
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -687
- package/src/RedactoNoticeConsentInline/api/index.ts +0 -106
- package/src/RedactoNoticeConsentInline/api/types.ts +0 -183
- package/src/RedactoNoticeConsentInline/assets/redacto-logo.png +0 -0
- package/src/RedactoNoticeConsentInline/index.ts +0 -1
- package/src/RedactoNoticeConsentInline/injectStyles.ts +0 -40
- package/src/RedactoNoticeConsentInline/styles.ts +0 -397
- package/src/RedactoNoticeConsentInline/types.ts +0 -51
- package/src/RedactoNoticeConsentInline/useMediaQuery.ts +0 -36
- package/src/RedactoPrivacyCenter/NotificationsPage.tsx +0 -182
- package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +0 -19
- package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +0 -156
- package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +0 -17
- package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +0 -79
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +0 -36
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +0 -177
- package/src/RedactoPrivacyCenter/PrivacyCenterMyReceiptPage.tsx +0 -26
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +0 -58
- package/src/RedactoPrivacyCenter/api/actions.ts +0 -695
- package/src/RedactoPrivacyCenter/api/client.ts +0 -911
- package/src/RedactoPrivacyCenter/api/config.ts +0 -33
- package/src/RedactoPrivacyCenter/api/fetcher.ts +0 -260
- package/src/RedactoPrivacyCenter/api/orgBranding.ts +0 -16
- package/src/RedactoPrivacyCenter/api/upload.ts +0 -93
- package/src/RedactoPrivacyCenter/assets/character.svg +0 -31
- package/src/RedactoPrivacyCenter/assets/fileAttach.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/greenTick.svg +0 -4
- package/src/RedactoPrivacyCenter/assets/mail.svg +0 -3
- package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/sentImg.svg +0 -9
- package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +0 -9
- package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +0 -252
- package/src/RedactoPrivacyCenter/components/ActivityTimeline.tsx +0 -239
- package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +0 -268
- package/src/RedactoPrivacyCenter/components/AppShell/MobileNavDrawer.tsx +0 -218
- package/src/RedactoPrivacyCenter/components/AppShell/NavItem.tsx +0 -205
- package/src/RedactoPrivacyCenter/components/AppShell/PageHeader.tsx +0 -107
- package/src/RedactoPrivacyCenter/components/AppShell/SidebarNav.tsx +0 -100
- package/src/RedactoPrivacyCenter/components/AppShell/TopAppBar.tsx +0 -339
- package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +0 -245
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useIsTruncated.ts +0 -28
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useKeyboardShortcut.ts +0 -40
- package/src/RedactoPrivacyCenter/components/AppShell/hooks/useSidebarState.ts +0 -62
- package/src/RedactoPrivacyCenter/components/AppShell/usePrivacyCenterExitAction.ts +0 -36
- package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +0 -38
- package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +0 -31
- package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +0 -79
- package/src/RedactoPrivacyCenter/components/CaseDetails/FinalizedTooltipWrapper.tsx +0 -25
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +0 -96
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +0 -97
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +0 -156
- package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +0 -171
- package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +0 -23
- package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +0 -116
- package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +0 -43
- package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +0 -71
- package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +0 -469
- package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +0 -426
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +0 -1206
- package/src/RedactoPrivacyCenter/components/ConsentManagerCells.tsx +0 -218
- package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -138
- package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +0 -7
- package/src/RedactoPrivacyCenter/components/ConsentManagerExpandedRow.tsx +0 -179
- package/src/RedactoPrivacyCenter/components/ConsentManagerGroupedColumns.tsx +0 -318
- package/src/RedactoPrivacyCenter/components/ConsentManagerProductCards.tsx +0 -333
- package/src/RedactoPrivacyCenter/components/ConsentManagerPurposeCards.tsx +0 -102
- package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +0 -159
- package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +0 -189
- package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +0 -118
- package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +0 -66
- package/src/RedactoPrivacyCenter/components/DataRequest/ProductSelectionSection.tsx +0 -222
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +0 -331
- package/src/RedactoPrivacyCenter/components/Form.tsx +0 -1796
- package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +0 -174
- package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +0 -337
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +0 -334
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationBell.tsx +0 -173
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationCTAButton.tsx +0 -18
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdown.tsx +0 -54
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationDropdownItem.tsx +0 -57
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationIcon.tsx +0 -19
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationListPanel.tsx +0 -92
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRefreshButton.tsx +0 -20
- package/src/RedactoPrivacyCenter/components/NotificationHub/NotificationRow.tsx +0 -58
- package/src/RedactoPrivacyCenter/components/NotificationHub/StatCard.tsx +0 -10
- package/src/RedactoPrivacyCenter/components/NotificationHub/useNotificationActions.ts +0 -112
- package/src/RedactoPrivacyCenter/components/NotificationHub/utils.ts +0 -27
- package/src/RedactoPrivacyCenter/components/Pagination.tsx +0 -140
- package/src/RedactoPrivacyCenter/components/PrivacyCenterEmptyState.tsx +0 -76
- package/src/RedactoPrivacyCenter/components/PrivacyCenterSessionExpired.tsx +0 -97
- package/src/RedactoPrivacyCenter/components/ReceiptsList.tsx +0 -454
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +0 -270
- package/src/RedactoPrivacyCenter/components/SelectUser.tsx +0 -95
- package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +0 -187
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +0 -25
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +0 -21
- package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +0 -159
- package/src/RedactoPrivacyCenter/components/constant.ts +0 -52
- package/src/RedactoPrivacyCenter/components/groupHelpers.ts +0 -119
- package/src/RedactoPrivacyCenter/components/receiptColumns.tsx +0 -203
- package/src/RedactoPrivacyCenter/components/types.ts +0 -123
- package/src/RedactoPrivacyCenter/components/utils.tsx +0 -10
- package/src/RedactoPrivacyCenter/config/routes.ts +0 -9
- package/src/RedactoPrivacyCenter/config/table.ts +0 -25
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +0 -160
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +0 -47
- package/src/RedactoPrivacyCenter/context/BrandingContext.tsx +0 -52
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.session-expired.test.tsx +0 -92
- package/src/RedactoPrivacyCenter/context/DataAvailabilityContext.tsx +0 -181
- package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +0 -11
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +0 -96
- package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +0 -30
- package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +0 -80
- package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +0 -15
- package/src/RedactoPrivacyCenter/context/types.ts +0 -19
- package/src/RedactoPrivacyCenter/lib/api.ts +0 -759
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +0 -67
- package/src/RedactoPrivacyCenter/lib/constants.ts +0 -276
- package/src/RedactoPrivacyCenter/lib/i18n.ts +0 -81
- package/src/RedactoPrivacyCenter/lib/locales.ts +0 -102
- package/src/RedactoPrivacyCenter/lib/queries.ts +0 -141
- package/src/RedactoPrivacyCenter/lib/types.ts +0 -876
- package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +0 -37
- package/src/RedactoPrivacyCenter/lib/utils.refresh-circuit.test.ts +0 -127
- package/src/RedactoPrivacyCenter/lib/utils.refresh-storage.test.ts +0 -85
- package/src/RedactoPrivacyCenter/lib/utils.session-expired.test.ts +0 -109
- package/src/RedactoPrivacyCenter/lib/utils.ts +0 -783
- package/src/RedactoPrivacyCenter/locales/as/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/en/translation.json +0 -357
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +0 -345
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/or/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/te/translation.json +0 -342
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +0 -342
- package/src/RedactoPrivacyCenter/styles/applyBrandingTheme.ts +0 -66
- package/src/RedactoPrivacyCenter/styles/base.css +0 -117
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +0 -3108
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -831
- package/src/RedactoPrivacyCenter/styles/responsive.ts +0 -138
- package/src/RedactoPrivacyCenter/styles/variables.css +0 -96
- package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +0 -387
- package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +0 -468
- package/src/RedactoPrivacyCenter/table/index.ts +0 -2
- package/src/RedactoPrivacyCenter/types/images.d.ts +0 -29
- package/src/RedactoPrivacyCenter/ui/Footer.tsx +0 -69
- package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +0 -54
- package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +0 -93
- package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +0 -47
- package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +0 -83
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +0 -93
- package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +0 -48
- package/src/RedactoPrivacyCenter/ui/PCButton.tsx +0 -77
- package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +0 -55
- package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +0 -107
- package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +0 -35
- package/src/RedactoPrivacyCenter/ui/PCInput.tsx +0 -132
- package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +0 -31
- package/src/RedactoPrivacyCenter/ui/PCModal.tsx +0 -106
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -206
- package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +0 -433
- package/src/RedactoPrivacyCenter/ui/PCTable.tsx +0 -149
- package/src/RedactoPrivacyCenter/ui/PCText.tsx +0 -41
- package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +0 -43
- package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +0 -37
- package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +0 -121
- package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +0 -25
- package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -53
- package/src/RedactoPrivacyCenter/ui/index.ts +0 -18
- package/src/RedactoPrivacyCenter/utils/cn.ts +0 -28
- package/src/RedactoPrivacyCenter/utils/date.ts +0 -48
- package/src/RedactoPrivacyCenter/utils/status.ts +0 -111
- package/src/RedactoPrivacyCenter/utils/toast.ts +0 -130
- package/src/index.ts +0 -19
- package/src/privacy-center.ts +0 -113
- package/src/shared/api-errors.ts +0 -180
- package/src/shared/notice-consent-status.ts +0 -190
- package/src/types/css.d.ts +0 -4
- package/src/types/images.d.ts +0 -19
- package/src/types/react-jsx.d.ts +0 -26
- package/tests/DataAvailabilityContext.test.tsx +0 -123
- package/tests/Form.test.tsx +0 -1287
- package/tests/ManageConsentErrorMapping.test.tsx +0 -85
- package/tests/ProductSelectionSection.test.tsx +0 -328
- package/tests/RequestSubmit.test.tsx +0 -135
- package/tests/RevocablePurpose.test.tsx +0 -146
- package/tests/RevokeWarningMessage.test.tsx +0 -151
- package/tests/consentClient.test.ts +0 -217
- package/tests/manageConsentServer.test.ts +0 -38
- package/tests/mocks.ts +0 -257
- package/tests/resolveContactFromJwt.test.ts +0 -44
- package/tests/setup.ts +0 -16
- package/tsconfig.json +0 -16
- package/tsup.config.ts +0 -31
- package/vitest.config.ts +0 -16
|
@@ -1,783 +0,0 @@
|
|
|
1
|
-
import { refreshOtpTokenServer } from './api';
|
|
2
|
-
import { isTerminalRefreshError } from '../api/fetcher';
|
|
3
|
-
import {
|
|
4
|
-
MIN_REFRESH_BACKOFF_MS,
|
|
5
|
-
MAX_REFRESH_BACKOFF_MS,
|
|
6
|
-
REFRESH_TOKEN_STORAGE_KEY,
|
|
7
|
-
LEGACY_REFRESH_TOKEN_COOKIE,
|
|
8
|
-
} from './constants';
|
|
9
|
-
import type { BaseUrlContextValue } from '../context/BaseUrlContext';
|
|
10
|
-
|
|
11
|
-
// Token refresh lock to prevent concurrent refresh attempts
|
|
12
|
-
let isRefreshing = false;
|
|
13
|
-
let refreshPromise: Promise<string | null> | null = null;
|
|
14
|
-
|
|
15
|
-
// ─── OTP refresh circuit breaker (incident 2026-06-22) ──────────────────────
|
|
16
|
-
// ensureValidToken is called from 3 independent 30s pollers, before every
|
|
17
|
-
// authenticated API call, and from render-driven effects. Without a memory of
|
|
18
|
-
// failure, a single dead refresh token gets re-submitted on every one of those
|
|
19
|
-
// triggers, hammering POST /otp/refresh until the tab closes. These guards make a
|
|
20
|
-
// terminally-failed token cost exactly one request, and apply backoff to transient
|
|
21
|
-
// failures.
|
|
22
|
-
//
|
|
23
|
-
// `deadRefreshToken` holds the *value* of a refresh token that terminally failed.
|
|
24
|
-
// It auto-clears the moment a different token value appears (i.e. the user
|
|
25
|
-
// re-authenticated), so the breaker never permanently bricks a session.
|
|
26
|
-
let deadRefreshToken: string | null = null;
|
|
27
|
-
// Transient-failure backoff window (epoch ms) and the current backoff duration.
|
|
28
|
-
let transientRefreshFailureUntil = 0;
|
|
29
|
-
let transientRefreshBackoffMs = 0;
|
|
30
|
-
|
|
31
|
-
// ─── Terminal "session expired" signal ──────────────────────────────────────
|
|
32
|
-
// The circuit breaker above stops the *network* (a dead refresh token costs one
|
|
33
|
-
// request). This listener lets the React tree learn that the session is
|
|
34
|
-
// terminally over so it can show a dedicated "session expired" screen and gate
|
|
35
|
-
// every further fetch — instead of silently rendering empty/stale content or
|
|
36
|
-
// hard-navigating away. A single module-level listener, consistent with the
|
|
37
|
-
// single-instance breaker singleton above (one Privacy Center per page).
|
|
38
|
-
let sessionExpiredListener: (() => void) | null = null;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Register (or clear, with `null`) the callback fired when a refresh terminally
|
|
42
|
-
* fails — i.e. the refresh token is expired/invalid and re-authentication is
|
|
43
|
-
* required. AuthProvider registers its `markSessionExpired` here. Exported for
|
|
44
|
-
* that wiring and for tests; not part of the package's public API.
|
|
45
|
-
*/
|
|
46
|
-
export const setSessionExpiredListener = (fn: (() => void) | null): void => {
|
|
47
|
-
sessionExpiredListener = fn;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/** Notify the React tree that the session has terminally expired. */
|
|
51
|
-
const notifySessionExpired = (): void => {
|
|
52
|
-
sessionExpiredListener?.();
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// Buffer time in seconds before token expiration to proactively refresh
|
|
56
|
-
// Using 30 seconds to ensure tokens are refreshed before they expire
|
|
57
|
-
const TOKEN_EXPIRY_BUFFER_SECONDS = 30;
|
|
58
|
-
|
|
59
|
-
export const getCookieValue = (name: string) => {
|
|
60
|
-
const cookies = document.cookie.split('; ');
|
|
61
|
-
for (let cookie of cookies) {
|
|
62
|
-
const [key, value] = cookie.split('=');
|
|
63
|
-
if (key === name) {
|
|
64
|
-
return value;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// ─── Refresh token persistence ──────────────────────────────────────────────
|
|
71
|
-
// The refresh token lives in localStorage (key REFRESH_TOKEN_STORAGE_KEY), not a
|
|
72
|
-
// cookie. A path-scoped cookie is not readable when the host mounts the privacy
|
|
73
|
-
// center under a basePath (e.g. consent-app at /consent → /consent/privacy-center/...),
|
|
74
|
-
// so a rotated token can't be read back and the stale token gets re-presented,
|
|
75
|
-
// causing a 401 "Refresh token not found or already used" storm. localStorage is
|
|
76
|
-
// origin-scoped (no path matching), so it is readable under any basePath.
|
|
77
|
-
//
|
|
78
|
-
// localStorage access throws when storage is disabled or over quota (private
|
|
79
|
-
// modes, embedded webviews). Guard every access so the SDK degrades to "refresh
|
|
80
|
-
// fails -> re-auth" instead of crashing the render tree (setRefreshTokenCookie
|
|
81
|
-
// runs during AuthProvider's render). Mirrors components/AppShell/hooks/useSidebarState.ts.
|
|
82
|
-
const readStorage = (key: string): string | null => {
|
|
83
|
-
try {
|
|
84
|
-
return window.localStorage.getItem(key);
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const writeStorage = (key: string, value: string): void => {
|
|
91
|
-
try {
|
|
92
|
-
window.localStorage.setItem(key, value);
|
|
93
|
-
} catch {
|
|
94
|
-
// localStorage unavailable / quota exceeded — ignore
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const removeStorage = (key: string): void => {
|
|
99
|
-
try {
|
|
100
|
-
window.localStorage.removeItem(key);
|
|
101
|
-
} catch {
|
|
102
|
-
// localStorage unavailable — ignore
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Expire the SDK's own legacy refresh_token cookie (older versions wrote it at
|
|
108
|
-
* path=/privacy-center). Scoped to that path only on purpose: a refresh_token
|
|
109
|
-
* cookie at path=/ belongs to the host app, and clearing it would sign the host
|
|
110
|
-
* out.
|
|
111
|
-
*/
|
|
112
|
-
const clearLegacyRefreshTokenCookie = (): void => {
|
|
113
|
-
if (typeof window === 'undefined') {
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
document.cookie = `${LEGACY_REFRESH_TOKEN_COOKIE}=; path=/privacy-center; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Persist the refresh token. Named `setRefreshTokenCookie` for backward
|
|
121
|
-
* compatibility (it is part of the package's exported surface); it now writes
|
|
122
|
-
* to localStorage rather than a cookie, and clears any cookie an older SDK
|
|
123
|
-
* version left at path=/privacy-center.
|
|
124
|
-
* @param refreshToken - The refresh token value to set
|
|
125
|
-
*/
|
|
126
|
-
export const setRefreshTokenCookie = (refreshToken: string): void => {
|
|
127
|
-
if (typeof window === 'undefined') {
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
writeStorage(REFRESH_TOKEN_STORAGE_KEY, refreshToken);
|
|
131
|
-
clearLegacyRefreshTokenCookie();
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Read the persisted refresh token from localStorage.
|
|
136
|
-
*
|
|
137
|
-
* Intentionally does NOT fall back to reading a `refresh_token` cookie: a cookie
|
|
138
|
-
* read is name-only (the path isn't visible to JS), so it cannot tell the SDK's
|
|
139
|
-
* own legacy cookie from a host app's unrelated `refresh_token` cookie — reading
|
|
140
|
-
* the latter would leak a host credential to the OTP refresh endpoint. Existing
|
|
141
|
-
* sessions are preserved by AuthProvider re-seeding storage from the token prop.
|
|
142
|
-
*/
|
|
143
|
-
export const getStoredRefreshToken = (): string | null => {
|
|
144
|
-
if (typeof window === 'undefined') {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
return readStorage(REFRESH_TOKEN_STORAGE_KEY);
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Resolve the refresh token currently in play: from auth context (props mode) or
|
|
152
|
-
* persisted storage (legacy mode). Centralised so the circuit breaker and the
|
|
153
|
-
* refresh routine always agree on exactly which token value is being used.
|
|
154
|
-
*/
|
|
155
|
-
const getActiveRefreshToken = (authContext?: { refreshToken?: string }): string | null => {
|
|
156
|
-
if (authContext) {
|
|
157
|
-
return authContext.refreshToken && authContext.refreshToken.trim() !== ''
|
|
158
|
-
? authContext.refreshToken
|
|
159
|
-
: null;
|
|
160
|
-
}
|
|
161
|
-
if (typeof window === 'undefined') return null;
|
|
162
|
-
return getStoredRefreshToken();
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Clear all OTP-refresh circuit-breaker state. Called after a successful refresh
|
|
167
|
-
* and exposed for tests. Deliberately NOT called from signOutPrivacyCenter: in
|
|
168
|
-
* props mode the refresh token lives in a prop that sign-out cannot clear, so
|
|
169
|
-
* clearing the breaker there would immediately re-arm the retry storm. Recovery
|
|
170
|
-
* instead happens automatically when a *different* token value appears.
|
|
171
|
-
*/
|
|
172
|
-
export const resetRefreshCircuit = (): void => {
|
|
173
|
-
deadRefreshToken = null;
|
|
174
|
-
transientRefreshFailureUntil = 0;
|
|
175
|
-
transientRefreshBackoffMs = 0;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* True when attempting a refresh right now would be pointless — the active refresh
|
|
180
|
-
* token has already terminally failed, or we're inside the transient backoff
|
|
181
|
-
* window. Pollers consult this to avoid scheduling doomed work.
|
|
182
|
-
*/
|
|
183
|
-
export const isRefreshBlocked = (authContext?: { refreshToken?: string }): boolean => {
|
|
184
|
-
const active = getActiveRefreshToken(authContext);
|
|
185
|
-
if (active !== null && active === deadRefreshToken) return true;
|
|
186
|
-
if (Date.now() < transientRefreshFailureUntil) return true;
|
|
187
|
-
return false;
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
/** Record a transient refresh failure (network / 5xx) and grow the backoff window. */
|
|
191
|
-
const noteTransientRefreshFailure = (): void => {
|
|
192
|
-
transientRefreshBackoffMs = Math.min(
|
|
193
|
-
Math.max(transientRefreshBackoffMs * 2, MIN_REFRESH_BACKOFF_MS),
|
|
194
|
-
MAX_REFRESH_BACKOFF_MS,
|
|
195
|
-
);
|
|
196
|
-
transientRefreshFailureUntil = Date.now() + transientRefreshBackoffMs;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
/** Trip the circuit breaker for a refresh token value that terminally failed. */
|
|
200
|
-
const markRefreshTokenDead = (token: string | null): void => {
|
|
201
|
-
if (token) {
|
|
202
|
-
deadRefreshToken = token;
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Decode JWT token and extract payload
|
|
208
|
-
* @param token - JWT token string
|
|
209
|
-
* @returns Decoded JWT payload or null if decoding fails
|
|
210
|
-
*/
|
|
211
|
-
export const decodeJwtToken = (token: string): { organisation_uuid?: string; organisation_name?: string; workspace_uuid?: string; [key: string]: any } | null => {
|
|
212
|
-
if (!token || typeof token !== 'string') {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
// JWT format: header.payload.signature
|
|
218
|
-
const parts = token.split('.');
|
|
219
|
-
if (parts.length !== 3) {
|
|
220
|
-
// Malformed JWT
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Decode payload (second part)
|
|
225
|
-
const payload = parts[1];
|
|
226
|
-
|
|
227
|
-
// Base64URL decode
|
|
228
|
-
// Replace URL-safe characters and add padding if needed
|
|
229
|
-
let base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
230
|
-
const padding = base64.length % 4;
|
|
231
|
-
if (padding) {
|
|
232
|
-
base64 += '='.repeat(4 - padding);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Decode base64
|
|
236
|
-
const decodedPayload = atob(base64);
|
|
237
|
-
const payloadObj = JSON.parse(decodedPayload) as { organisation_uuid?: string; organisation_name?: string; workspace_uuid?: string; [key: string]: any };
|
|
238
|
-
|
|
239
|
-
return payloadObj;
|
|
240
|
-
} catch (error) {
|
|
241
|
-
// If decoding fails, return null
|
|
242
|
-
console.error('Error decoding JWT:', error);
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Resolve the user's contact identifier from a decoded JWT.
|
|
249
|
-
*
|
|
250
|
-
* Identifiers live under `user_data` (primary_email / primary_mobile /
|
|
251
|
-
* org_user_id); some token shapes also expose `contact`/`primary_email` at the
|
|
252
|
-
* top level. Email is preferred, but a mobile-only login must still resolve to
|
|
253
|
-
* the phone (or org_user_id) so case/grievance and consent-management actions —
|
|
254
|
-
* which key off this contact — work without an email. The backend matches
|
|
255
|
-
* identifiers type-aware (phone-to-phone), so storing the mobile here is safe.
|
|
256
|
-
*/
|
|
257
|
-
export const resolveContactFromJwt = (
|
|
258
|
-
decoded: { [key: string]: any } | null,
|
|
259
|
-
): string | null => {
|
|
260
|
-
if (!decoded) {
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
const userData = (decoded.user_data as Record<string, unknown>) || {};
|
|
264
|
-
const candidate =
|
|
265
|
-
decoded.contact ||
|
|
266
|
-
decoded.primary_email ||
|
|
267
|
-
userData.primary_email ||
|
|
268
|
-
userData.primary_mobile ||
|
|
269
|
-
userData.org_user_id;
|
|
270
|
-
return typeof candidate === 'string' && candidate.trim() !== ''
|
|
271
|
-
? candidate
|
|
272
|
-
: null;
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Decode JWT payload and check if token is expired or will expire soon
|
|
277
|
-
* @param token - JWT token string
|
|
278
|
-
* @param bufferSeconds - Buffer time in seconds before expiration to consider token expired (default: 5 minutes)
|
|
279
|
-
* @returns true if token is expired, invalid, or will expire within buffer time; false if valid
|
|
280
|
-
*/
|
|
281
|
-
export const isJwtExpired = (token: string, bufferSeconds: number = 5 * 60): boolean => {
|
|
282
|
-
if (!token || typeof token !== 'string') {
|
|
283
|
-
return true;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
try {
|
|
287
|
-
// JWT format: header.payload.signature
|
|
288
|
-
const parts = token.split('.');
|
|
289
|
-
if (parts.length !== 3) {
|
|
290
|
-
// Malformed JWT
|
|
291
|
-
return true;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Decode payload (second part)
|
|
295
|
-
const payload = parts[1];
|
|
296
|
-
|
|
297
|
-
// Base64URL decode
|
|
298
|
-
// Replace URL-safe characters and add padding if needed
|
|
299
|
-
let base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
300
|
-
const padding = base64.length % 4;
|
|
301
|
-
if (padding) {
|
|
302
|
-
base64 += '='.repeat(4 - padding);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Decode base64
|
|
306
|
-
const decodedPayload = atob(base64);
|
|
307
|
-
const payloadObj: { exp?: number } = JSON.parse(decodedPayload) as { exp?: number };
|
|
308
|
-
|
|
309
|
-
// Check if exp claim exists
|
|
310
|
-
if (typeof payloadObj.exp !== 'number') {
|
|
311
|
-
// Missing exp claim - treat as invalid
|
|
312
|
-
return true;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Get current time in seconds
|
|
316
|
-
const now = Math.floor(Date.now() / 1000);
|
|
317
|
-
|
|
318
|
-
// Check if token is expired or will expire within buffer time
|
|
319
|
-
const isExpired = payloadObj.exp <= (now + bufferSeconds);
|
|
320
|
-
|
|
321
|
-
// exp is in seconds since epoch
|
|
322
|
-
return isExpired;
|
|
323
|
-
} catch (error) {
|
|
324
|
-
// If decoding fails, treat as expired/invalid
|
|
325
|
-
console.error('Error decoding JWT:', error);
|
|
326
|
-
return true;
|
|
327
|
-
}
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Check if user is authenticated in privacy center
|
|
332
|
-
* User is considered authenticated if both access_token and email exist in sessionStorage
|
|
333
|
-
*/
|
|
334
|
-
export const isUserAuthenticated = (expectedSlug: string): boolean => {
|
|
335
|
-
if (typeof window === 'undefined') return false;
|
|
336
|
-
const accessToken = sessionStorage.getItem('access_token');
|
|
337
|
-
const email = sessionStorage.getItem('email');
|
|
338
|
-
const orgSlug = sessionStorage.getItem('pc_org_slug');
|
|
339
|
-
|
|
340
|
-
// Require that tokens exist and the stored organization slug matches
|
|
341
|
-
if (!accessToken || !email || !orgSlug) {
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return orgSlug === expectedSlug;
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Get initial state for sign-in form visibility
|
|
350
|
-
* Returns true if window is undefined (SSR) or user is not authenticated
|
|
351
|
-
* @param slug - The organization slug to check authentication against
|
|
352
|
-
* @returns true if sign-in form should be shown, false otherwise
|
|
353
|
-
*/
|
|
354
|
-
export const getInitialState = (slug: string): boolean => {
|
|
355
|
-
if (typeof window === 'undefined') return true;
|
|
356
|
-
return !isUserAuthenticated(slug);
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Check authentication status and attempt token refresh if needed
|
|
361
|
-
* This function checks if the user is authenticated, validates JWT expiration,
|
|
362
|
-
* and attempts to refresh using the refresh token cookie if the token is expired or missing.
|
|
363
|
-
* @param expectedSlug - The organization slug to verify against
|
|
364
|
-
* @param organizationId - Optional organization ID (will be read from sessionStorage if not provided)
|
|
365
|
-
* @param workspaceId - Optional workspace ID (will be read from sessionStorage if not provided)
|
|
366
|
-
* @returns Promise<boolean> - true if authenticated with valid non-expired token, false otherwise
|
|
367
|
-
*/
|
|
368
|
-
export const checkAndRefreshAuth = async (
|
|
369
|
-
expectedSlug: string,
|
|
370
|
-
organizationId?: string,
|
|
371
|
-
workspaceId?: string,
|
|
372
|
-
consentClient?: BaseUrlContextValue['consentClient']
|
|
373
|
-
): Promise<boolean> => {
|
|
374
|
-
if (typeof window === 'undefined') return false;
|
|
375
|
-
|
|
376
|
-
const accessToken = sessionStorage.getItem('access_token');
|
|
377
|
-
const email = sessionStorage.getItem('email');
|
|
378
|
-
const orgSlug = sessionStorage.getItem('pc_org_slug');
|
|
379
|
-
|
|
380
|
-
// If slug doesn't match, user is not authenticated for this organization
|
|
381
|
-
if (orgSlug && orgSlug !== expectedSlug) {
|
|
382
|
-
return false;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Check if we need to refresh (token missing or expired)
|
|
386
|
-
const needsRefresh = !accessToken || (accessToken && isJwtExpired(accessToken, TOKEN_EXPIRY_BUFFER_SECONDS));
|
|
387
|
-
|
|
388
|
-
// If we have a valid token and all required data, user is authenticated
|
|
389
|
-
if (!needsRefresh && accessToken && email && orgSlug && orgSlug === expectedSlug) {
|
|
390
|
-
return true;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// If token is missing or expired, and we have email and orgSlug, try to refresh
|
|
394
|
-
if (needsRefresh && email && orgSlug === expectedSlug) {
|
|
395
|
-
// Get organizationId and workspaceId from sessionStorage if not provided
|
|
396
|
-
const orgId = organizationId || sessionStorage.getItem('pc_org_id') || '';
|
|
397
|
-
const wsId = workspaceId || sessionStorage.getItem('pc_workspace_id') || '';
|
|
398
|
-
|
|
399
|
-
if (!orgId || !wsId) {
|
|
400
|
-
return false;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const refreshToken = getStoredRefreshToken();
|
|
404
|
-
if (!refreshToken) {
|
|
405
|
-
// No refresh token available - terminal: surface the session-expired screen.
|
|
406
|
-
signOutPrivacyCenter();
|
|
407
|
-
notifySessionExpired();
|
|
408
|
-
return false;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Circuit breaker (incident 2026-06-22): a new token re-arms; a known-dead
|
|
412
|
-
// token or an open backoff window short-circuits without hitting the network.
|
|
413
|
-
if (deadRefreshToken !== null && refreshToken !== deadRefreshToken) {
|
|
414
|
-
resetRefreshCircuit();
|
|
415
|
-
}
|
|
416
|
-
if (refreshToken === deadRefreshToken || Date.now() < transientRefreshFailureUntil) {
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
try {
|
|
421
|
-
const result = await refreshOtpTokenServer(orgId, wsId, refreshToken, consentClient);
|
|
422
|
-
if (result && result.success !== false) {
|
|
423
|
-
const newAccessToken = (result as { access_token?: string })?.access_token;
|
|
424
|
-
const newRefreshToken = (result as { refresh_token?: string })?.refresh_token;
|
|
425
|
-
|
|
426
|
-
if (newAccessToken) {
|
|
427
|
-
// Validate the new token is not expired before storing
|
|
428
|
-
if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
|
|
429
|
-
console.error('Refreshed access token is already expired - clearing auth state');
|
|
430
|
-
markRefreshTokenDead(refreshToken);
|
|
431
|
-
signOutPrivacyCenter();
|
|
432
|
-
notifySessionExpired();
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
435
|
-
sessionStorage.setItem('access_token', newAccessToken);
|
|
436
|
-
// Success — re-arm the circuit breaker.
|
|
437
|
-
resetRefreshCircuit();
|
|
438
|
-
} else {
|
|
439
|
-
// No access token in response - refresh failed
|
|
440
|
-
console.error('Refresh response missing access token - clearing auth state');
|
|
441
|
-
markRefreshTokenDead(refreshToken);
|
|
442
|
-
signOutPrivacyCenter();
|
|
443
|
-
notifySessionExpired();
|
|
444
|
-
return false;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (newRefreshToken) {
|
|
448
|
-
setRefreshTokenCookie(newRefreshToken);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Verify we now have a valid, non-expired access token
|
|
452
|
-
const isValid = !!newAccessToken && !isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS);
|
|
453
|
-
if (!isValid) {
|
|
454
|
-
console.error('Refreshed token validation failed - clearing auth state');
|
|
455
|
-
signOutPrivacyCenter();
|
|
456
|
-
}
|
|
457
|
-
return isValid;
|
|
458
|
-
} else {
|
|
459
|
-
// Refresh token expired/revoked or server returned failure - terminal.
|
|
460
|
-
markRefreshTokenDead(refreshToken);
|
|
461
|
-
signOutPrivacyCenter();
|
|
462
|
-
notifySessionExpired();
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
} catch (error: any) {
|
|
466
|
-
if (isTerminalRefreshError(error)) {
|
|
467
|
-
// Refresh token is expired / not found / already used - trip the breaker.
|
|
468
|
-
markRefreshTokenDead(refreshToken);
|
|
469
|
-
signOutPrivacyCenter();
|
|
470
|
-
notifySessionExpired();
|
|
471
|
-
return false;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Transient (network / 5xx): keep auth state, back off, retry on a later call.
|
|
475
|
-
noteTransientRefreshFailure();
|
|
476
|
-
console.error('[Token Refresh] Unexpected error refreshing token:', error);
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
return false;
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Compare two email addresses (case-insensitive, handles URL encoding)
|
|
486
|
-
* @param email1 - First email (can be URL-encoded)
|
|
487
|
-
* @param email2 - Second email
|
|
488
|
-
* @returns true if emails match (case-insensitive), false otherwise
|
|
489
|
-
*/
|
|
490
|
-
export const compareEmails = (email1: string | null, email2: string | null): boolean => {
|
|
491
|
-
if (!email1 || !email2) {
|
|
492
|
-
return false;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Decode URL-encoded email (handles %40 for @, %2B for +, etc.)
|
|
496
|
-
const decodedEmail1 = decodeURIComponent(email1.trim());
|
|
497
|
-
const normalizedEmail1 = decodedEmail1.toLowerCase();
|
|
498
|
-
const normalizedEmail2 = email2.trim().toLowerCase();
|
|
499
|
-
|
|
500
|
-
return normalizedEmail1 === normalizedEmail2;
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Sign out from privacy center only
|
|
505
|
-
* Clears privacy-center specific storage and cookies without touching Supabase auth
|
|
506
|
-
*/
|
|
507
|
-
export const signOutPrivacyCenter = (): void => {
|
|
508
|
-
if (typeof window === 'undefined') return;
|
|
509
|
-
|
|
510
|
-
const privacyCenterKeys = [
|
|
511
|
-
'access_token',
|
|
512
|
-
'email',
|
|
513
|
-
'i18nextLng',
|
|
514
|
-
'lang',
|
|
515
|
-
'pc_org_slug',
|
|
516
|
-
'pc_org_id',
|
|
517
|
-
'pc_workspace_id',
|
|
518
|
-
'pc_current_page',
|
|
519
|
-
'pc_grievance_view',
|
|
520
|
-
'pc_selected_case_uuid',
|
|
521
|
-
];
|
|
522
|
-
privacyCenterKeys.forEach((key) => {
|
|
523
|
-
sessionStorage.removeItem(key);
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
// Clear the persisted refresh token (and the SDK's own legacy cookie from
|
|
527
|
-
// older versions). The host app's path=/ cookie is intentionally left alone.
|
|
528
|
-
removeStorage(REFRESH_TOKEN_STORAGE_KEY);
|
|
529
|
-
clearLegacyRefreshTokenCookie();
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* Ensure a valid access token is available before making API calls.
|
|
534
|
-
* This function checks if the current token is valid (not expired).
|
|
535
|
-
* If expired or missing, it attempts to refresh using the refresh token.
|
|
536
|
-
* If refresh fails (refresh token expired), it signs out and redirects to sign-in.
|
|
537
|
-
*
|
|
538
|
-
* @param organizationId - The organization UUID
|
|
539
|
-
* @param workspaceId - The workspace UUID
|
|
540
|
-
* @param slug - The organization slug for redirect
|
|
541
|
-
* @param authContext - Optional auth context values (accessToken, refreshToken, onError, updateTokens)
|
|
542
|
-
* @returns Promise<string | null> - Valid access token or null if auth failed
|
|
543
|
-
*/
|
|
544
|
-
export const ensureValidToken = async (
|
|
545
|
-
organizationId?: string,
|
|
546
|
-
workspaceId?: string,
|
|
547
|
-
slug?: string,
|
|
548
|
-
authContext?: {
|
|
549
|
-
accessToken?: string;
|
|
550
|
-
refreshToken?: string;
|
|
551
|
-
onError?: (error: Error) => boolean;
|
|
552
|
-
updateTokens?: (accessToken: string, refreshToken?: string) => void;
|
|
553
|
-
},
|
|
554
|
-
consentClient?: BaseUrlContextValue['consentClient']
|
|
555
|
-
): Promise<string | null> => {
|
|
556
|
-
if (typeof window === 'undefined') return null;
|
|
557
|
-
|
|
558
|
-
// Use context accessToken if provided, otherwise fall back to sessionStorage
|
|
559
|
-
const accessToken = authContext?.accessToken || sessionStorage.getItem('access_token');
|
|
560
|
-
|
|
561
|
-
// Check if current token is valid (exists, is a string, and not expired)
|
|
562
|
-
// Use a buffer to proactively refresh before actual expiry
|
|
563
|
-
if (
|
|
564
|
-
accessToken &&
|
|
565
|
-
typeof accessToken === 'string' &&
|
|
566
|
-
accessToken.trim() !== '' &&
|
|
567
|
-
!isJwtExpired(accessToken, TOKEN_EXPIRY_BUFFER_SECONDS)
|
|
568
|
-
) {
|
|
569
|
-
return accessToken;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// Circuit breaker: before any network work, consult the failure memory.
|
|
573
|
-
// (Incident 2026-06-22 — stops the /otp/refresh retry storm at its source.)
|
|
574
|
-
const activeRefreshToken = getActiveRefreshToken(authContext);
|
|
575
|
-
// A new refresh token value means the user re-authenticated — re-arm the breaker.
|
|
576
|
-
if (deadRefreshToken !== null && activeRefreshToken !== deadRefreshToken) {
|
|
577
|
-
resetRefreshCircuit();
|
|
578
|
-
}
|
|
579
|
-
// Known-dead token: do not touch the network until a different token appears.
|
|
580
|
-
if (activeRefreshToken !== null && activeRefreshToken === deadRefreshToken) {
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
// Transient-failure backoff window still open: skip the network.
|
|
584
|
-
if (Date.now() < transientRefreshFailureUntil) {
|
|
585
|
-
return null;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Token is missing or expired, need to refresh
|
|
589
|
-
// Use lock to prevent concurrent refresh attempts
|
|
590
|
-
if (isRefreshing && refreshPromise) {
|
|
591
|
-
// Wait for ongoing refresh to complete
|
|
592
|
-
const newToken = await refreshPromise;
|
|
593
|
-
// Return the new token directly (it's already stored in sessionStorage/context)
|
|
594
|
-
return newToken;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Start refresh process
|
|
598
|
-
isRefreshing = true;
|
|
599
|
-
refreshPromise = performTokenRefresh(organizationId, workspaceId, slug, authContext, consentClient);
|
|
600
|
-
|
|
601
|
-
try {
|
|
602
|
-
const newToken = await refreshPromise;
|
|
603
|
-
// Return the new token directly (it's already stored in sessionStorage/context)
|
|
604
|
-
return newToken;
|
|
605
|
-
} finally {
|
|
606
|
-
isRefreshing = false;
|
|
607
|
-
refreshPromise = null;
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Internal function to perform token refresh
|
|
613
|
-
* @returns Promise<string | null> - The new access token on success, null on failure
|
|
614
|
-
*/
|
|
615
|
-
const performTokenRefresh = async (
|
|
616
|
-
organizationId?: string,
|
|
617
|
-
workspaceId?: string,
|
|
618
|
-
// Retained for call-site compatibility; redirect-by-slug was removed in favor
|
|
619
|
-
// of the terminal "session expired" screen, so the value is intentionally unused.
|
|
620
|
-
_slug?: string,
|
|
621
|
-
authContext?: {
|
|
622
|
-
accessToken?: string;
|
|
623
|
-
refreshToken?: string;
|
|
624
|
-
onError?: (error: Error) => boolean;
|
|
625
|
-
updateTokens?: (accessToken: string, refreshToken?: string) => void;
|
|
626
|
-
},
|
|
627
|
-
consentClient?: BaseUrlContextValue['consentClient']
|
|
628
|
-
): Promise<string | null> => {
|
|
629
|
-
// Get organizationId and workspaceId from sessionStorage if not provided
|
|
630
|
-
const orgId = organizationId || sessionStorage.getItem('pc_org_id') || '';
|
|
631
|
-
const wsId = workspaceId || sessionStorage.getItem('pc_workspace_id') || '';
|
|
632
|
-
|
|
633
|
-
if (!orgId || !wsId) {
|
|
634
|
-
console.error('[Token Refresh] Missing organizationId or workspaceId');
|
|
635
|
-
const error = new Error('Missing organizationId or workspaceId');
|
|
636
|
-
// Inform the host (informational only — we no longer auto-navigate; the
|
|
637
|
-
// component surfaces a terminal "session expired" screen instead).
|
|
638
|
-
authContext?.onError?.(error);
|
|
639
|
-
signOutPrivacyCenter();
|
|
640
|
-
notifySessionExpired();
|
|
641
|
-
return null;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Resolve the refresh token (props mode → context, legacy mode → storage) via
|
|
645
|
-
// the shared helper so the value matches what the circuit breaker keyed on.
|
|
646
|
-
const refreshTokenValue: string | null = getActiveRefreshToken(authContext);
|
|
647
|
-
|
|
648
|
-
// Validate refreshToken is a non-empty string
|
|
649
|
-
if (!refreshTokenValue || typeof refreshTokenValue !== 'string' || refreshTokenValue.trim() === '') {
|
|
650
|
-
// No refresh token available - user needs to sign in again
|
|
651
|
-
// Log presence only — never the raw token, which would leak a credential to
|
|
652
|
-
// browser log collectors.
|
|
653
|
-
console.warn('[Token Refresh] No valid refresh token available', {
|
|
654
|
-
hasAuthContext: !!authContext,
|
|
655
|
-
hasRefreshTokenInContext: !!authContext?.refreshToken?.trim(),
|
|
656
|
-
hasRefreshTokenInStorage: !!getStoredRefreshToken(),
|
|
657
|
-
});
|
|
658
|
-
const error = new Error('No refresh token available');
|
|
659
|
-
// Inform the host (informational only — we no longer auto-navigate).
|
|
660
|
-
authContext?.onError?.(error);
|
|
661
|
-
signOutPrivacyCenter();
|
|
662
|
-
notifySessionExpired();
|
|
663
|
-
return null;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// At this point, refreshTokenValue is guaranteed to be a non-empty string
|
|
667
|
-
const refreshToken: string = refreshTokenValue;
|
|
668
|
-
|
|
669
|
-
try {
|
|
670
|
-
const result = await refreshOtpTokenServer(orgId, wsId, refreshToken, consentClient);
|
|
671
|
-
|
|
672
|
-
if (result && result.success !== false) {
|
|
673
|
-
const newAccessToken = (result as { access_token?: string })?.access_token;
|
|
674
|
-
const newRefreshToken = (result as { refresh_token?: string })?.refresh_token;
|
|
675
|
-
|
|
676
|
-
if (newAccessToken) {
|
|
677
|
-
// Validate the new token is not expired before storing
|
|
678
|
-
if (isJwtExpired(newAccessToken, TOKEN_EXPIRY_BUFFER_SECONDS)) {
|
|
679
|
-
console.error('[Token Refresh] New access token is already expired');
|
|
680
|
-
markRefreshTokenDead(refreshToken);
|
|
681
|
-
const error = new Error('New access token is already expired');
|
|
682
|
-
// Inform the host (informational only — we no longer auto-navigate).
|
|
683
|
-
authContext?.onError?.(error);
|
|
684
|
-
signOutPrivacyCenter();
|
|
685
|
-
notifySessionExpired();
|
|
686
|
-
return null;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Update tokens via context if available, otherwise use storage
|
|
690
|
-
if (authContext?.updateTokens) {
|
|
691
|
-
authContext.updateTokens(newAccessToken, newRefreshToken);
|
|
692
|
-
} else {
|
|
693
|
-
sessionStorage.setItem('access_token', newAccessToken);
|
|
694
|
-
if (newRefreshToken) {
|
|
695
|
-
setRefreshTokenCookie(newRefreshToken);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// Success — re-arm the circuit breaker so future failures are tracked afresh.
|
|
700
|
-
resetRefreshCircuit();
|
|
701
|
-
// Return the new access token directly (already stored in sessionStorage/context)
|
|
702
|
-
return newAccessToken;
|
|
703
|
-
} else {
|
|
704
|
-
// No access token in response - refresh failed
|
|
705
|
-
console.error('[Token Refresh] Response missing access token');
|
|
706
|
-
markRefreshTokenDead(refreshToken);
|
|
707
|
-
const error = new Error('Response missing access token');
|
|
708
|
-
// Inform the host (informational only — we no longer auto-navigate).
|
|
709
|
-
authContext?.onError?.(error);
|
|
710
|
-
signOutPrivacyCenter();
|
|
711
|
-
notifySessionExpired();
|
|
712
|
-
return null;
|
|
713
|
-
}
|
|
714
|
-
} else {
|
|
715
|
-
// Refresh token expired/revoked or server returned failure - terminal.
|
|
716
|
-
markRefreshTokenDead(refreshToken);
|
|
717
|
-
const error = new Error('Refresh token expired or revoked');
|
|
718
|
-
// Inform the host (informational only — we no longer auto-navigate).
|
|
719
|
-
authContext?.onError?.(error);
|
|
720
|
-
signOutPrivacyCenter();
|
|
721
|
-
notifySessionExpired();
|
|
722
|
-
return null;
|
|
723
|
-
}
|
|
724
|
-
} catch (error: any) {
|
|
725
|
-
if (isTerminalRefreshError(error)) {
|
|
726
|
-
// Terminal: refresh token expired / not found / already used / 4xx. Trip the
|
|
727
|
-
// breaker so this dead token is never re-submitted, then surface the
|
|
728
|
-
// session-expired screen (onError is informational; we no longer navigate).
|
|
729
|
-
markRefreshTokenDead(refreshToken);
|
|
730
|
-
const err = new Error('Refresh token expired or revoked');
|
|
731
|
-
authContext?.onError?.(err);
|
|
732
|
-
signOutPrivacyCenter();
|
|
733
|
-
notifySessionExpired();
|
|
734
|
-
return null;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Transient (network / 5xx): keep auth state, back off, and let a later call retry.
|
|
738
|
-
noteTransientRefreshFailure();
|
|
739
|
-
console.error('[Token Refresh] Unexpected error:', error);
|
|
740
|
-
// Still call onError for unexpected errors
|
|
741
|
-
if (authContext?.onError) {
|
|
742
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
743
|
-
authContext.onError(err);
|
|
744
|
-
}
|
|
745
|
-
return null;
|
|
746
|
-
}
|
|
747
|
-
};
|
|
748
|
-
|
|
749
|
-
// Activity helper functions
|
|
750
|
-
|
|
751
|
-
export type ActivityBadgeStatus =
|
|
752
|
-
| 'success'
|
|
753
|
-
| 'warning'
|
|
754
|
-
| 'error'
|
|
755
|
-
| 'info'
|
|
756
|
-
| 'pending';
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Get badge status based on activity type
|
|
760
|
-
*/
|
|
761
|
-
export const getActivityTypeStatus = (
|
|
762
|
-
activityType: string
|
|
763
|
-
): ActivityBadgeStatus => {
|
|
764
|
-
const type = activityType.toLowerCase();
|
|
765
|
-
if (type.includes('consent') || type.includes('grant')) return 'success';
|
|
766
|
-
if (type.includes('revoke') || type.includes('withdraw')) return 'error';
|
|
767
|
-
if (type.includes('update') || type.includes('modify')) return 'warning';
|
|
768
|
-
if (type.includes('view') || type.includes('access')) return 'info';
|
|
769
|
-
return 'pending';
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Format activity type for display
|
|
774
|
-
*/
|
|
775
|
-
export const formatActivityType = (activityType: string): string => {
|
|
776
|
-
return activityType
|
|
777
|
-
.split('_')
|
|
778
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
779
|
-
.join(' ');
|
|
780
|
-
};
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
export const getVar = (varName: string): string => `var(${varName})`;
|