@djangocfg/api 2.1.87 → 2.1.88

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.87",
3
+ "version": "2.1.88",
4
4
  "description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
5
5
  "keywords": [
6
6
  "django",
@@ -74,7 +74,7 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/ui-nextjs": "^2.1.87",
77
+ "@djangocfg/ui-nextjs": "^2.1.88",
78
78
  "consola": "^3.4.2",
79
79
  "next": "^14 || ^15",
80
80
  "p-retry": "^7.0.0",
@@ -85,7 +85,7 @@
85
85
  "devDependencies": {
86
86
  "@types/node": "^24.7.2",
87
87
  "@types/react": "^19.0.0",
88
- "@djangocfg/typescript-config": "^2.1.87",
88
+ "@djangocfg/typescript-config": "^2.1.88",
89
89
  "next": "^15.0.0",
90
90
  "react": "^19.0.0",
91
91
  "tsup": "^8.5.0",
@@ -11,6 +11,7 @@ import { useCfgRouter, useLocalStorage, useQueryParams } from '@djangocfg/ui-nex
11
11
  import { api as apiAccounts, Enums } from '../../';
12
12
  import { clearProfileCache, getCachedProfile } from '../hooks/useProfileCache';
13
13
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
14
+ import { useTokenRefresh } from '../hooks/useTokenRefresh';
14
15
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
15
16
  import { authLogger } from '../utils/logger';
16
17
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
@@ -65,6 +66,18 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
65
66
  const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(EMAIL_STORAGE_KEY, null);
66
67
  const [storedPhone, setStoredPhone, clearStoredPhone] = useLocalStorage<string | null>(PHONE_STORAGE_KEY, null);
67
68
 
69
+ // Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect
70
+ useTokenRefresh({
71
+ enabled: true,
72
+ onRefresh: (newToken) => {
73
+ authLogger.info('Token auto-refreshed successfully');
74
+ },
75
+ onRefreshError: (error) => {
76
+ authLogger.warn('Token auto-refresh failed:', error.message);
77
+ // Don't logout on refresh error - user might still have valid session
78
+ },
79
+ });
80
+
68
81
  // Map AccountsContext profile to UserProfile
69
82
  const user = accounts.profile as UserProfile | null;
70
83
 
@@ -95,14 +108,26 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
95
108
  }, []);
96
109
 
97
110
  // Global error handler for auth-related errors
111
+ // Only clears auth on actual authentication errors (401), not on any API error
98
112
  const handleGlobalAuthError = useCallback((error: any, context: string = 'API Request') => {
99
- // Simple error check - if response has error flag, it's an error
100
- if (error?.success === false) {
101
- authLogger.warn(`Error detected in ${context}, clearing tokens`);
113
+ // Only clear auth on actual authentication errors (401)
114
+ // Don't logout on validation errors, server errors, network issues, etc.
115
+ const isAuthError = error?.status === 401 ||
116
+ error?.statusCode === 401 ||
117
+ error?.code === 'token_not_valid' ||
118
+ error?.code === 'authentication_failed';
119
+
120
+ if (isAuthError) {
121
+ authLogger.warn(`Authentication error in ${context}, clearing tokens`);
102
122
  clearAuthState(`globalAuthError:${context}`);
103
123
  return true;
104
124
  }
105
125
 
126
+ // Log but don't logout for other errors
127
+ if (error?.success === false) {
128
+ authLogger.warn(`Non-auth error in ${context} (not clearing session):`, error?.message || error);
129
+ }
130
+
106
131
  return false;
107
132
  }, [clearAuthState]);
108
133
 
@@ -150,11 +175,22 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
150
175
  // Always mark as initialized if we have valid tokens
151
176
  // Don't clear tokens just because profile fetch failed
152
177
  setInitialized(true);
153
- } catch (error) {
178
+ } catch (error: any) {
154
179
  authLogger.error('Failed to load profile:', error);
155
- // Use global error handler first, fallback to clearing state
156
- if (!handleGlobalAuthError(error, 'loadCurrentProfile')) {
157
- clearAuthState('loadCurrentProfile:error');
180
+ // Only clear auth state on actual authentication errors (401)
181
+ // Don't logout on network errors, server errors, etc.
182
+ const isAuthError = error?.status === 401 ||
183
+ error?.statusCode === 401 ||
184
+ error?.code === 'token_not_valid' ||
185
+ error?.code === 'authentication_failed';
186
+
187
+ if (isAuthError) {
188
+ authLogger.warn('Authentication error, clearing session');
189
+ clearAuthState('loadCurrentProfile:authError');
190
+ } else {
191
+ // Keep tokens, mark as initialized - user can retry
192
+ authLogger.warn('Profile load failed but keeping session (non-auth error)');
193
+ setInitialized(true);
158
194
  }
159
195
  } finally {
160
196
  isLoadingProfileRef.current = false;
@@ -214,10 +250,20 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
214
250
  try {
215
251
  authLogger.info('No cached profile found, loading from API...');
216
252
  await loadCurrentProfile('AuthContext.initializeAuth');
217
- } catch (error) {
253
+ } catch (error: any) {
218
254
  authLogger.error('Failed to load profile during initialization:', error);
219
- // If profile loading fails, clear auth state
220
- clearAuthState('initializeAuth:loadProfileFailed');
255
+ // Only clear on 401 auth error, otherwise keep session
256
+ const isAuthError = error?.status === 401 ||
257
+ error?.statusCode === 401 ||
258
+ error?.code === 'token_not_valid' ||
259
+ error?.code === 'authentication_failed';
260
+
261
+ if (isAuthError) {
262
+ clearAuthState('initializeAuth:authError');
263
+ } else {
264
+ authLogger.warn('Init profile load failed but keeping session');
265
+ setInitialized(true);
266
+ }
221
267
  }
222
268
  setIsLoading(false);
223
269
  } else {
@@ -45,3 +45,6 @@ export {
45
45
  getCacheMetadata,
46
46
  type ProfileCacheOptions,
47
47
  } from './useProfileCache';
48
+
49
+ // Token refresh
50
+ export { useTokenRefresh } from './useTokenRefresh';
@@ -16,7 +16,7 @@ import { decodeBase64, encodeBase64 } from './useBase64';
16
16
  // Cache configuration
17
17
  const CACHE_KEY = 'user_profile_cache';
18
18
  const CACHE_VERSION = 1;
19
- const DEFAULT_TTL = 3600000; // 1 hour in milliseconds
19
+ const DEFAULT_TTL = 14400000; // 4 hours in milliseconds (reduced API calls, more resilient)
20
20
 
21
21
  export interface ProfileCacheOptions {
22
22
  /** Time to live in milliseconds (default: 1 hour) */
@@ -0,0 +1,167 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Token Refresh Hook
5
+ *
6
+ * Provides automatic token refresh functionality:
7
+ * - Proactively refreshes token before expiry
8
+ * - Refreshes on window focus
9
+ * - Refreshes on network reconnect
10
+ */
11
+
12
+ import { useCallback, useEffect, useRef } from 'react';
13
+ import { api as apiAccounts } from '../../';
14
+ import { authLogger } from '../utils/logger';
15
+
16
+ // Configuration
17
+ const TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes before expiry
18
+ const CHECK_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
19
+
20
+ interface UseTokenRefreshOptions {
21
+ /** Enable automatic token refresh (default: true) */
22
+ enabled?: boolean;
23
+ /** Callback when token is refreshed */
24
+ onRefresh?: (newToken: string) => void;
25
+ /** Callback when refresh fails */
26
+ onRefreshError?: (error: Error) => void;
27
+ }
28
+
29
+ /**
30
+ * Decode JWT and get expiry time
31
+ */
32
+ function getTokenExpiry(token: string): number | null {
33
+ try {
34
+ const payload = JSON.parse(atob(token.split('.')[1]));
35
+ return payload.exp * 1000; // Convert to milliseconds
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Check if token is expiring soon
43
+ */
44
+ function isTokenExpiringSoon(token: string, thresholdMs: number): boolean {
45
+ const expiry = getTokenExpiry(token);
46
+ if (!expiry) return false;
47
+
48
+ const timeUntilExpiry = expiry - Date.now();
49
+ return timeUntilExpiry < thresholdMs;
50
+ }
51
+
52
+ /**
53
+ * Hook for automatic token refresh
54
+ */
55
+ export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
56
+ const { enabled = true, onRefresh, onRefreshError } = options;
57
+ const isRefreshingRef = useRef(false);
58
+
59
+ /**
60
+ * Refresh the token
61
+ */
62
+ const refreshToken = useCallback(async (): Promise<boolean> => {
63
+ if (isRefreshingRef.current) {
64
+ authLogger.debug('Token refresh already in progress');
65
+ return false;
66
+ }
67
+
68
+ const refreshTokenValue = apiAccounts.getRefreshToken();
69
+ if (!refreshTokenValue) {
70
+ authLogger.warn('No refresh token available');
71
+ return false;
72
+ }
73
+
74
+ isRefreshingRef.current = true;
75
+ authLogger.info('Refreshing token...');
76
+
77
+ try {
78
+ const response = await fetch('/api/accounts/token/refresh/', {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ refresh: refreshTokenValue }),
82
+ });
83
+
84
+ if (!response.ok) {
85
+ throw new Error(`Token refresh failed: ${response.status}`);
86
+ }
87
+
88
+ const data = await response.json();
89
+ const newAccessToken = data.access;
90
+
91
+ if (!newAccessToken) {
92
+ throw new Error('No access token in refresh response');
93
+ }
94
+
95
+ apiAccounts.setToken(newAccessToken, refreshTokenValue);
96
+ authLogger.info('Token refreshed successfully');
97
+
98
+ onRefresh?.(newAccessToken);
99
+ return true;
100
+ } catch (error) {
101
+ authLogger.error('Token refresh error:', error);
102
+ onRefreshError?.(error instanceof Error ? error : new Error(String(error)));
103
+ return false;
104
+ } finally {
105
+ isRefreshingRef.current = false;
106
+ }
107
+ }, [onRefresh, onRefreshError]);
108
+
109
+ /**
110
+ * Check and refresh if needed
111
+ */
112
+ const checkAndRefresh = useCallback(async () => {
113
+ const token = apiAccounts.getToken();
114
+ if (!token) return;
115
+
116
+ if (isTokenExpiringSoon(token, TOKEN_REFRESH_THRESHOLD_MS)) {
117
+ authLogger.info('Token expiring soon, refreshing proactively');
118
+ await refreshToken();
119
+ }
120
+ }, [refreshToken]);
121
+
122
+ // Periodic check
123
+ useEffect(() => {
124
+ if (!enabled) return;
125
+
126
+ // Check immediately
127
+ checkAndRefresh();
128
+
129
+ // Set up interval
130
+ const intervalId = setInterval(checkAndRefresh, CHECK_INTERVAL_MS);
131
+
132
+ return () => clearInterval(intervalId);
133
+ }, [enabled, checkAndRefresh]);
134
+
135
+ // Refresh on window focus
136
+ useEffect(() => {
137
+ if (!enabled) return;
138
+
139
+ const handleFocus = () => {
140
+ authLogger.debug('Window focused, checking token...');
141
+ checkAndRefresh();
142
+ };
143
+
144
+ window.addEventListener('focus', handleFocus);
145
+ return () => window.removeEventListener('focus', handleFocus);
146
+ }, [enabled, checkAndRefresh]);
147
+
148
+ // Refresh on network reconnect
149
+ useEffect(() => {
150
+ if (!enabled) return;
151
+
152
+ const handleOnline = () => {
153
+ authLogger.info('Network reconnected, checking token...');
154
+ checkAndRefresh();
155
+ };
156
+
157
+ window.addEventListener('online', handleOnline);
158
+ return () => window.removeEventListener('online', handleOnline);
159
+ }, [enabled, checkAndRefresh]);
160
+
161
+ return {
162
+ refreshToken,
163
+ checkAndRefresh,
164
+ };
165
+ }
166
+
167
+ export default useTokenRefresh;
@@ -1 +1,8 @@
1
- export { proxyMiddleware, proxyMiddlewareConfig } from './proxy';
1
+ export { proxyMiddleware, proxyMiddlewareConfig } from './proxy';
2
+ export {
3
+ refreshAccessToken,
4
+ createAutoRefreshFetch,
5
+ isTokenExpiringSoon,
6
+ refreshIfExpiringSoon,
7
+ isAuthenticationError,
8
+ } from './tokenRefresh';
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Token Auto-Refresh Middleware
3
+ *
4
+ * Automatically refreshes access token when receiving 401 responses.
5
+ * Implements request queuing to prevent multiple simultaneous refresh attempts.
6
+ */
7
+
8
+ import { api as apiAccounts } from '../../';
9
+ import { authLogger } from '../utils/logger';
10
+
11
+ // Refresh state management
12
+ let isRefreshing = false;
13
+ let refreshSubscribers: Array<(token: string | null) => void> = [];
14
+
15
+ /**
16
+ * Subscribe to token refresh completion
17
+ */
18
+ function subscribeTokenRefresh(callback: (token: string | null) => void): void {
19
+ refreshSubscribers.push(callback);
20
+ }
21
+
22
+ /**
23
+ * Notify all subscribers when token is refreshed
24
+ */
25
+ function onTokenRefreshed(token: string | null): void {
26
+ refreshSubscribers.forEach(callback => callback(token));
27
+ refreshSubscribers = [];
28
+ }
29
+
30
+ /**
31
+ * Attempt to refresh the access token using the refresh token
32
+ * @returns New access token or null if refresh failed
33
+ */
34
+ export async function refreshAccessToken(): Promise<string | null> {
35
+ // If already refreshing, wait for completion
36
+ if (isRefreshing) {
37
+ return new Promise(resolve => {
38
+ subscribeTokenRefresh(token => resolve(token));
39
+ });
40
+ }
41
+
42
+ isRefreshing = true;
43
+ authLogger.info('Starting token refresh...');
44
+
45
+ try {
46
+ const refreshToken = apiAccounts.getRefreshToken();
47
+ if (!refreshToken) {
48
+ authLogger.warn('No refresh token available for refresh');
49
+ onTokenRefreshed(null);
50
+ return null;
51
+ }
52
+
53
+ // Call the refresh endpoint
54
+ const response = await fetch('/api/accounts/token/refresh/', {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ },
59
+ body: JSON.stringify({ refresh: refreshToken }),
60
+ });
61
+
62
+ if (!response.ok) {
63
+ authLogger.error('Token refresh failed with status:', response.status);
64
+ onTokenRefreshed(null);
65
+ return null;
66
+ }
67
+
68
+ const data = await response.json();
69
+ const newAccessToken = data.access;
70
+
71
+ if (!newAccessToken) {
72
+ authLogger.error('Token refresh response missing access token');
73
+ onTokenRefreshed(null);
74
+ return null;
75
+ }
76
+
77
+ // Update tokens in storage
78
+ apiAccounts.setToken(newAccessToken, refreshToken);
79
+ authLogger.info('Token refreshed successfully');
80
+
81
+ onTokenRefreshed(newAccessToken);
82
+ return newAccessToken;
83
+ } catch (error) {
84
+ authLogger.error('Token refresh error:', error);
85
+ onTokenRefreshed(null);
86
+ return null;
87
+ } finally {
88
+ isRefreshing = false;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Check if a response indicates an authentication error
94
+ */
95
+ export function isAuthenticationError(response: Response): boolean {
96
+ return response.status === 401;
97
+ }
98
+
99
+ /**
100
+ * Create a fetch wrapper that automatically refreshes tokens on 401
101
+ *
102
+ * @param originalFetch - The original fetch function to wrap
103
+ * @returns Wrapped fetch function with auto-refresh capability
104
+ */
105
+ export function createAutoRefreshFetch(originalFetch: typeof fetch): typeof fetch {
106
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
107
+ // Make the initial request
108
+ let response = await originalFetch(input, init);
109
+
110
+ // If 401 and we have a refresh token, try to refresh
111
+ if (isAuthenticationError(response) && apiAccounts.getRefreshToken()) {
112
+ authLogger.info('Received 401, attempting token refresh...');
113
+
114
+ const newToken = await refreshAccessToken();
115
+
116
+ if (newToken) {
117
+ // Retry the request with new token
118
+ const newInit: RequestInit = {
119
+ ...init,
120
+ headers: {
121
+ ...init?.headers,
122
+ Authorization: `Bearer ${newToken}`,
123
+ },
124
+ };
125
+
126
+ authLogger.info('Retrying request with new token...');
127
+ response = await originalFetch(input, newInit);
128
+ } else {
129
+ authLogger.warn('Token refresh failed, returning original 401 response');
130
+ }
131
+ }
132
+
133
+ return response;
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Check if token is about to expire (within threshold)
139
+ * @param thresholdMs - Time in ms before expiry to consider "expiring soon"
140
+ * @returns true if token expires within threshold
141
+ */
142
+ export function isTokenExpiringSoon(thresholdMs: number = 5 * 60 * 1000): boolean {
143
+ const token = apiAccounts.getToken();
144
+ if (!token) return false;
145
+
146
+ try {
147
+ // Decode JWT payload (base64)
148
+ const payload = JSON.parse(atob(token.split('.')[1]));
149
+ const expiresAt = payload.exp * 1000; // Convert to milliseconds
150
+ const now = Date.now();
151
+ const timeUntilExpiry = expiresAt - now;
152
+
153
+ return timeUntilExpiry < thresholdMs;
154
+ } catch (error) {
155
+ authLogger.error('Error checking token expiry:', error);
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Proactively refresh token if it's expiring soon
162
+ * Call this periodically or before important operations
163
+ */
164
+ export async function refreshIfExpiringSoon(thresholdMs: number = 5 * 60 * 1000): Promise<void> {
165
+ if (isTokenExpiringSoon(thresholdMs)) {
166
+ authLogger.info('Token expiring soon, proactively refreshing...');
167
+ await refreshAccessToken();
168
+ }
169
+ }