feat: add token refresh lifecycle callbacks to CalProvider (#25203)

Add three new optional callbacks to CalProvider that enable users to react during the access token refresh lifecycle:
- onTokenRefreshStart: Called when token refresh begins
- onTokenRefreshSuccess: Called when token refresh succeeds
- onTokenRefreshError: Called when token refresh fails with error message

These callbacks are invoked in both token refresh locations:
1. Response interceptor (when API returns 498 status)
2. Initial access token validation

The callbacks are properly passed through the component hierarchy:
CalProvider -> BaseCalProvider -> useOAuthFlow hook

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
Morgan
2026-01-27 14:47:56 +05:30
committed by GitHub
co-authored by Devin AI Rajiv Sahal
parent e625308881
commit bf39489899
3 changed files with 60 additions and 24 deletions
@@ -42,6 +42,9 @@ export type CalProviderProps = {
options: { refreshUrl?: string; apiUrl: string; readingDirection?: "ltr" | "rtl" };
autoUpdateTimezone?: boolean;
onTimezoneChange?: () => void;
onTokenRefreshStart?: () => void;
onTokenRefreshSuccess?: () => void;
onTokenRefreshError?: (error: string) => void;
version?: API_VERSIONS_ENUM;
organizationId?: number;
isEmbed?: boolean;
@@ -57,6 +60,9 @@ export function BaseCalProvider({
language = EN,
organizationId,
onTimezoneChange,
onTokenRefreshStart,
onTokenRefreshSuccess,
onTokenRefreshError,
isEmbed,
}: CalProviderProps) {
const [error, setError] = useState<string>("");
@@ -100,6 +106,9 @@ export function BaseCalProvider({
onSuccess: () => {
setError("");
},
onTokenRefreshStart,
onTokenRefreshSuccess,
onTokenRefreshError,
clientId,
});
@@ -23,6 +23,9 @@ const queryClient = new QueryClient();
* @param {string} [options.refreshUrl] - The url point to your refresh endpoint. - Optional, required if accessToken is provided.
* @param {boolean} [autoUpdateTimezone=true] - Whether to automatically update the timezone. - Optional
* @param {function} props.onTimezoneChange - The callback function for timezone change. - Optional
* @param {function} props.onTokenRefreshStart - The callback function called when token refresh starts. - Optional
* @param {function} props.onTokenRefreshSuccess - The callback function called when token refresh succeeds. - Optional
* @param {function} props.onTokenRefreshError - The callback function called when token refresh fails. - Optional
* @param {ReactNode} props.children - The child components. - Optional
* @returns {JSX.Element} The rendered CalProvider component.
*/
@@ -35,6 +38,9 @@ export function CalProvider({
labels,
language = "en",
onTimezoneChange,
onTokenRefreshStart,
onTokenRefreshSuccess,
onTokenRefreshError,
version = VERSION_2024_06_14,
organizationId,
isEmbed = false,
@@ -59,6 +65,9 @@ export function CalProvider({
isEmbed={isEmbed}
autoUpdateTimezone={autoUpdateTimezone}
onTimezoneChange={onTimezoneChange}
onTokenRefreshStart={onTokenRefreshStart}
onTokenRefreshSuccess={onTokenRefreshSuccess}
onTokenRefreshError={onTokenRefreshError}
clientId={clientId}
accessToken={accessToken}
options={options}
+42 -24
View File
@@ -1,5 +1,5 @@
import type { AxiosError, AxiosRequestConfig } from "axios";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import { useEffect, useState } from "react";
import usePrevious from "react-use/lib/usePrevious";
@@ -13,11 +13,23 @@ export interface useOAuthProps {
refreshUrl?: string;
onError?: (error: string) => void;
onSuccess?: () => void;
onTokenRefreshStart?: () => void;
onTokenRefreshSuccess?: () => void;
onTokenRefreshError?: (error: string) => void;
clientId: string;
}
const debouncedRefresh = debounce(http.refreshTokens, 10000, { leading: true, trailing: false });
export const useOAuthFlow = ({ accessToken, refreshUrl, clientId, onError, onSuccess }: useOAuthProps) => {
export const useOAuthFlow = ({
accessToken,
refreshUrl,
clientId,
onError,
onSuccess,
onTokenRefreshStart,
onTokenRefreshSuccess,
onTokenRefreshError,
}: useOAuthProps) => {
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [clientAccessToken, setClientAccessToken] = useState<string>("");
const prevAccessToken = usePrevious(accessToken);
@@ -28,16 +40,20 @@ export const useOAuthFlow = ({ accessToken, refreshUrl, clientId, onError, onSuc
const originalRequest = err.config as AxiosRequestConfig;
if (refreshUrl && err.response?.status === 498 && !isRefreshing) {
setIsRefreshing(true);
onTokenRefreshStart?.();
const refreshedToken = await debouncedRefresh(refreshUrl);
if (refreshedToken) {
setClientAccessToken(refreshedToken);
onSuccess?.();
onTokenRefreshSuccess?.();
return http.instance({
...originalRequest,
headers: { ...originalRequest.headers, Authorization: `Bearer ${refreshedToken}` },
});
} else {
onError?.("Invalid Refresh Token.");
const errorMessage = "Invalid Refresh Token.";
onError?.(errorMessage);
onTokenRefreshError?.(errorMessage);
}
setIsRefreshing(false);
@@ -51,33 +67,35 @@ export const useOAuthFlow = ({ accessToken, refreshUrl, clientId, onError, onSuc
http.responseInterceptor.eject(interceptorId);
}
};
}, [clientAccessToken, isRefreshing, refreshUrl, onError, onSuccess]);
}, [clientAccessToken, isRefreshing, refreshUrl, onError, onSuccess, onTokenRefreshStart, onTokenRefreshSuccess, onTokenRefreshError]);
useEffect(() => {
if (accessToken && http.getUrl() && prevAccessToken !== accessToken) {
http.setAuthorizationHeader(accessToken);
try {
http
.get<ApiResponse>(`/provider/${clientId}/access-token`)
.catch(async (err: AxiosError) => {
if ((err.response?.status === 498 || err.response?.status === 401) && refreshUrl) {
setIsRefreshing(true);
const refreshedToken = await http.refreshTokens(refreshUrl);
if (refreshedToken) {
setClientAccessToken(refreshedToken);
onSuccess?.();
} else {
onError?.("Invalid Refresh Token.");
}
setIsRefreshing(false);
http
.get<ApiResponse>(`/provider/${clientId}/access-token`)
.catch(async (err: AxiosError) => {
if ((err.response?.status === 498 || err.response?.status === 401) && refreshUrl) {
setIsRefreshing(true);
onTokenRefreshStart?.();
const refreshedToken = await http.refreshTokens(refreshUrl);
if (refreshedToken) {
setClientAccessToken(refreshedToken);
onSuccess?.();
onTokenRefreshSuccess?.();
} else {
const errorMessage = "Invalid Refresh Token.";
onError?.(errorMessage);
onTokenRefreshError?.(errorMessage);
}
})
.finally(() => {
setClientAccessToken(accessToken);
});
} catch (err) {}
setIsRefreshing(false);
}
})
.finally(() => {
setClientAccessToken(accessToken);
});
}
}, [accessToken, clientId, refreshUrl, prevAccessToken, onError, onSuccess]);
}, [accessToken, clientId, refreshUrl, prevAccessToken, onError, onSuccess, onTokenRefreshStart, onTokenRefreshSuccess, onTokenRefreshError]);
return { isRefreshing, currentAccessToken: clientAccessToken };
};