@djangocfg/api 2.1.87 → 2.1.89
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/dist/auth-server.cjs +1963 -4
- package/dist/auth-server.cjs.map +1 -1
- package/dist/auth-server.d.cts +35 -1
- package/dist/auth-server.d.ts +35 -1
- package/dist/auth-server.mjs +1953 -4
- package/dist/auth-server.mjs.map +1 -1
- package/dist/auth.cjs +692 -497
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +18 -2
- package/dist/auth.d.ts +18 -2
- package/dist/auth.mjs +655 -460
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +460 -383
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +26 -6
- package/dist/clients.d.ts +26 -6
- package/dist/clients.mjs +460 -383
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +130 -105
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.d.cts +24 -4
- package/dist/hooks.d.ts +24 -4
- package/dist/hooks.mjs +130 -105
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +373 -311
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -8
- package/dist/index.d.ts +54 -8
- package/dist/index.mjs +373 -311
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/auth/context/AccountsContext.tsx +3 -3
- package/src/auth/context/AuthContext.tsx +56 -10
- package/src/auth/hooks/index.ts +3 -0
- package/src/auth/hooks/useProfileCache.ts +1 -1
- package/src/auth/hooks/useTokenRefresh.ts +161 -0
- package/src/auth/middlewares/index.ts +8 -1
- package/src/auth/middlewares/tokenRefresh.ts +158 -0
- package/src/generated/cfg_accounts/CLAUDE.md +2 -9
- package/src/generated/cfg_accounts/_utils/fetchers/accounts__user_profile.ts +2 -1
- package/src/generated/cfg_accounts/_utils/hooks/accounts__user_profile.ts +2 -1
- package/src/generated/cfg_accounts/_utils/schemas/CfgAccountsProfileAvatarCreateRequest.schema.ts +15 -0
- package/src/generated/cfg_accounts/_utils/schemas/index.ts +1 -0
- package/src/generated/cfg_accounts/accounts/models.ts +0 -1
- package/src/generated/cfg_accounts/accounts__user_profile/client.ts +4 -2
- package/src/generated/cfg_accounts/accounts__user_profile/models.ts +9 -1
- package/src/generated/cfg_accounts/client.ts +18 -0
- package/src/generated/cfg_accounts/index.ts +3 -1
- package/src/generated/cfg_accounts/schema.json +2 -2
- package/src/generated/cfg_centrifugo/CLAUDE.md +1 -8
- package/src/generated/cfg_centrifugo/client.ts +18 -0
- package/src/generated/cfg_centrifugo/index.ts +3 -1
- package/src/generated/cfg_totp/CLAUDE.md +1 -8
- package/src/generated/cfg_totp/client.ts +18 -0
- package/src/generated/cfg_totp/index.ts +3 -1
- package/src/generated/cfg_totp/totp/models.ts +2 -0
- package/src/generated/cfg_webpush/CLAUDE.md +1 -8
- package/src/generated/cfg_webpush/client.ts +18 -0
- package/src/generated/cfg_webpush/index.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.89",
|
|
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.
|
|
77
|
+
"@djangocfg/ui-nextjs": "^2.1.89",
|
|
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.
|
|
88
|
+
"@djangocfg/typescript-config": "^2.1.89",
|
|
89
89
|
"next": "^15.0.0",
|
|
90
90
|
"react": "^19.0.0",
|
|
91
91
|
"tsup": "^8.5.0",
|
|
@@ -59,7 +59,7 @@ export interface AccountsContextValue {
|
|
|
59
59
|
// Profile operations
|
|
60
60
|
updateProfile: (data: UserProfileUpdateRequest) => Promise<User>;
|
|
61
61
|
partialUpdateProfile: (data: PatchedUserProfileUpdateRequest) => Promise<User>;
|
|
62
|
-
uploadAvatar: (
|
|
62
|
+
uploadAvatar: (avatar: File | Blob) => Promise<User>;
|
|
63
63
|
refreshProfile: (callerId?: string) => Promise<User | undefined>;
|
|
64
64
|
|
|
65
65
|
// Authentication
|
|
@@ -165,8 +165,8 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
165
165
|
};
|
|
166
166
|
|
|
167
167
|
// Upload avatar
|
|
168
|
-
const uploadAvatar = async (
|
|
169
|
-
const result = await avatarMutation(
|
|
168
|
+
const uploadAvatar = async (avatar: File | Blob): Promise<User> => {
|
|
169
|
+
const result = await avatarMutation({ avatar }, apiAccounts);
|
|
170
170
|
await refreshProfile('AccountsContext.uploadAvatar');
|
|
171
171
|
return result as User;
|
|
172
172
|
};
|
|
@@ -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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
//
|
|
220
|
-
|
|
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 {
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -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 =
|
|
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,161 @@
|
|
|
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
|
+
// Use generated API client with correct URL (/cfg/accounts/token/refresh/)
|
|
79
|
+
const result = await apiAccounts.auth.accountsTokenRefreshCreate({
|
|
80
|
+
refresh: refreshTokenValue,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const newAccessToken = result.access;
|
|
84
|
+
|
|
85
|
+
if (!newAccessToken) {
|
|
86
|
+
throw new Error('No access token in refresh response');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
apiAccounts.setToken(newAccessToken, refreshTokenValue);
|
|
90
|
+
authLogger.info('Token refreshed successfully');
|
|
91
|
+
|
|
92
|
+
onRefresh?.(newAccessToken);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
authLogger.error('Token refresh error:', error);
|
|
96
|
+
onRefreshError?.(error instanceof Error ? error : new Error(String(error)));
|
|
97
|
+
return false;
|
|
98
|
+
} finally {
|
|
99
|
+
isRefreshingRef.current = false;
|
|
100
|
+
}
|
|
101
|
+
}, [onRefresh, onRefreshError]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check and refresh if needed
|
|
105
|
+
*/
|
|
106
|
+
const checkAndRefresh = useCallback(async () => {
|
|
107
|
+
const token = apiAccounts.getToken();
|
|
108
|
+
if (!token) return;
|
|
109
|
+
|
|
110
|
+
if (isTokenExpiringSoon(token, TOKEN_REFRESH_THRESHOLD_MS)) {
|
|
111
|
+
authLogger.info('Token expiring soon, refreshing proactively');
|
|
112
|
+
await refreshToken();
|
|
113
|
+
}
|
|
114
|
+
}, [refreshToken]);
|
|
115
|
+
|
|
116
|
+
// Periodic check
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!enabled) return;
|
|
119
|
+
|
|
120
|
+
// Check immediately
|
|
121
|
+
checkAndRefresh();
|
|
122
|
+
|
|
123
|
+
// Set up interval
|
|
124
|
+
const intervalId = setInterval(checkAndRefresh, CHECK_INTERVAL_MS);
|
|
125
|
+
|
|
126
|
+
return () => clearInterval(intervalId);
|
|
127
|
+
}, [enabled, checkAndRefresh]);
|
|
128
|
+
|
|
129
|
+
// Refresh on window focus
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!enabled) return;
|
|
132
|
+
|
|
133
|
+
const handleFocus = () => {
|
|
134
|
+
authLogger.debug('Window focused, checking token...');
|
|
135
|
+
checkAndRefresh();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
window.addEventListener('focus', handleFocus);
|
|
139
|
+
return () => window.removeEventListener('focus', handleFocus);
|
|
140
|
+
}, [enabled, checkAndRefresh]);
|
|
141
|
+
|
|
142
|
+
// Refresh on network reconnect
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!enabled) return;
|
|
145
|
+
|
|
146
|
+
const handleOnline = () => {
|
|
147
|
+
authLogger.info('Network reconnected, checking token...');
|
|
148
|
+
checkAndRefresh();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
window.addEventListener('online', handleOnline);
|
|
152
|
+
return () => window.removeEventListener('online', handleOnline);
|
|
153
|
+
}, [enabled, checkAndRefresh]);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
refreshToken,
|
|
157
|
+
checkAndRefresh,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
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,158 @@
|
|
|
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
|
+
// Use generated API client with correct URL (/cfg/accounts/token/refresh/)
|
|
54
|
+
const result = await apiAccounts.auth.accountsTokenRefreshCreate({
|
|
55
|
+
refresh: refreshToken,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const newAccessToken = result.access;
|
|
59
|
+
|
|
60
|
+
if (!newAccessToken) {
|
|
61
|
+
authLogger.error('Token refresh response missing access token');
|
|
62
|
+
onTokenRefreshed(null);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Update tokens in storage
|
|
67
|
+
apiAccounts.setToken(newAccessToken, refreshToken);
|
|
68
|
+
authLogger.info('Token refreshed successfully');
|
|
69
|
+
|
|
70
|
+
onTokenRefreshed(newAccessToken);
|
|
71
|
+
return newAccessToken;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
authLogger.error('Token refresh error:', error);
|
|
74
|
+
onTokenRefreshed(null);
|
|
75
|
+
return null;
|
|
76
|
+
} finally {
|
|
77
|
+
isRefreshing = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if a response indicates an authentication error
|
|
83
|
+
*/
|
|
84
|
+
export function isAuthenticationError(response: Response): boolean {
|
|
85
|
+
return response.status === 401;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a fetch wrapper that automatically refreshes tokens on 401
|
|
90
|
+
*
|
|
91
|
+
* @param originalFetch - The original fetch function to wrap
|
|
92
|
+
* @returns Wrapped fetch function with auto-refresh capability
|
|
93
|
+
*/
|
|
94
|
+
export function createAutoRefreshFetch(originalFetch: typeof fetch): typeof fetch {
|
|
95
|
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
96
|
+
// Make the initial request
|
|
97
|
+
let response = await originalFetch(input, init);
|
|
98
|
+
|
|
99
|
+
// If 401 and we have a refresh token, try to refresh
|
|
100
|
+
if (isAuthenticationError(response) && apiAccounts.getRefreshToken()) {
|
|
101
|
+
authLogger.info('Received 401, attempting token refresh...');
|
|
102
|
+
|
|
103
|
+
const newToken = await refreshAccessToken();
|
|
104
|
+
|
|
105
|
+
if (newToken) {
|
|
106
|
+
// Retry the request with new token
|
|
107
|
+
const newInit: RequestInit = {
|
|
108
|
+
...init,
|
|
109
|
+
headers: {
|
|
110
|
+
...init?.headers,
|
|
111
|
+
Authorization: `Bearer ${newToken}`,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
authLogger.info('Retrying request with new token...');
|
|
116
|
+
response = await originalFetch(input, newInit);
|
|
117
|
+
} else {
|
|
118
|
+
authLogger.warn('Token refresh failed, returning original 401 response');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return response;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if token is about to expire (within threshold)
|
|
128
|
+
* @param thresholdMs - Time in ms before expiry to consider "expiring soon"
|
|
129
|
+
* @returns true if token expires within threshold
|
|
130
|
+
*/
|
|
131
|
+
export function isTokenExpiringSoon(thresholdMs: number = 5 * 60 * 1000): boolean {
|
|
132
|
+
const token = apiAccounts.getToken();
|
|
133
|
+
if (!token) return false;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Decode JWT payload (base64)
|
|
137
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
138
|
+
const expiresAt = payload.exp * 1000; // Convert to milliseconds
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const timeUntilExpiry = expiresAt - now;
|
|
141
|
+
|
|
142
|
+
return timeUntilExpiry < thresholdMs;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
authLogger.error('Error checking token expiry:', error);
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Proactively refresh token if it's expiring soon
|
|
151
|
+
* Call this periodically or before important operations
|
|
152
|
+
*/
|
|
153
|
+
export async function refreshIfExpiringSoon(thresholdMs: number = 5 * 60 * 1000): Promise<void> {
|
|
154
|
+
if (isTokenExpiringSoon(thresholdMs)) {
|
|
155
|
+
authLogger.info('Token expiring soon, proactively refreshing...');
|
|
156
|
+
await refreshAccessToken();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -12,7 +12,7 @@ python manage.py generate_client --groups cfg_accounts --typescript
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| Version | 3.0.3 |
|
|
14
14
|
| Operations | 14 |
|
|
15
|
-
| Schemas |
|
|
15
|
+
| Schemas | 20 |
|
|
16
16
|
|
|
17
17
|
## Resources
|
|
18
18
|
|
|
@@ -81,12 +81,5 @@ openapi_client = OpenAPIClientConfig(
|
|
|
81
81
|
)
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
**Copy to Next.js** (if `nextjs_admin` configured):
|
|
85
|
-
```python
|
|
86
|
-
nextjs_admin = NextJsAdminConfig(
|
|
87
|
-
project_path="../frontend/apps/...",
|
|
88
|
-
api_output_path="app/_lib/api/generated",
|
|
89
|
-
)
|
|
90
|
-
```
|
|
91
|
-
|
|
92
84
|
@see https://djangocfg.com/docs/features/api-generation
|
|
85
|
+
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* ```
|
|
32
32
|
*/
|
|
33
33
|
import { consola } from 'consola'
|
|
34
|
+
import { CfgAccountsProfileAvatarCreateRequestSchema, type CfgAccountsProfileAvatarCreateRequest } from '../schemas/CfgAccountsProfileAvatarCreateRequest.schema'
|
|
34
35
|
import { PatchedUserProfileUpdateRequestSchema, type PatchedUserProfileUpdateRequest } from '../schemas/PatchedUserProfileUpdateRequest.schema'
|
|
35
36
|
import { UserSchema, type User } from '../schemas/User.schema'
|
|
36
37
|
import { UserProfileUpdateRequestSchema, type UserProfileUpdateRequest } from '../schemas/UserProfileUpdateRequest.schema'
|
|
@@ -99,7 +100,7 @@ export async function getAccountsProfileRetrieve( client?: any
|
|
|
99
100
|
* @method POST
|
|
100
101
|
* @path /cfg/accounts/profile/avatar/
|
|
101
102
|
*/
|
|
102
|
-
export async function createAccountsProfileAvatarCreate( data:
|
|
103
|
+
export async function createAccountsProfileAvatarCreate( data: CfgAccountsProfileAvatarCreateRequest, client?: any
|
|
103
104
|
): Promise<User> {
|
|
104
105
|
const api = client || getAPIInstance()
|
|
105
106
|
const response = await api.user_profile.accountsProfileAvatarCreate(data)
|
|
@@ -21,6 +21,7 @@ import useSWR from 'swr'
|
|
|
21
21
|
import { useSWRConfig } from 'swr'
|
|
22
22
|
import * as Fetchers from '../fetchers/accounts__user_profile'
|
|
23
23
|
import type { API } from '../../index'
|
|
24
|
+
import type { CfgAccountsProfileAvatarCreateRequest } from '../schemas/CfgAccountsProfileAvatarCreateRequest.schema'
|
|
24
25
|
import type { PatchedUserProfileUpdateRequest } from '../schemas/PatchedUserProfileUpdateRequest.schema'
|
|
25
26
|
import type { User } from '../schemas/User.schema'
|
|
26
27
|
import type { UserProfileUpdateRequest } from '../schemas/UserProfileUpdateRequest.schema'
|
|
@@ -48,7 +49,7 @@ export function useAccountsProfileRetrieve(client?: API): ReturnType<typeof useS
|
|
|
48
49
|
export function useCreateAccountsProfileAvatarCreate() {
|
|
49
50
|
const { mutate } = useSWRConfig()
|
|
50
51
|
|
|
51
|
-
return async (data:
|
|
52
|
+
return async (data: CfgAccountsProfileAvatarCreateRequest, client?: API): Promise<User> => {
|
|
52
53
|
const result = await Fetchers.createAccountsProfileAvatarCreate(data, client)
|
|
53
54
|
// Revalidate related queries
|
|
54
55
|
mutate('cfg-accounts-profile-avatar')
|
package/src/generated/cfg_accounts/_utils/schemas/CfgAccountsProfileAvatarCreateRequest.schema.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for CfgAccountsProfileAvatarCreateRequest
|
|
3
|
+
*
|
|
4
|
+
* This schema provides runtime validation and type inference.
|
|
5
|
+
* */
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
export const CfgAccountsProfileAvatarCreateRequestSchema = z.object({
|
|
9
|
+
avatar: z.union([z.instanceof(File), z.instanceof(Blob)]),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Infer TypeScript type from Zod schema
|
|
14
|
+
*/
|
|
15
|
+
export type CfgAccountsProfileAvatarCreateRequest = z.infer<typeof CfgAccountsProfileAvatarCreateRequestSchema>
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
export * from './CentrifugoToken.schema'
|
|
21
|
+
export * from './CfgAccountsProfileAvatarCreateRequest.schema'
|
|
21
22
|
export * from './OAuthAuthorizeRequestRequest.schema'
|
|
22
23
|
export * from './OAuthAuthorizeResponse.schema'
|
|
23
24
|
export * from './OAuthCallbackRequestRequest.schema'
|
|
@@ -27,8 +27,10 @@ export class UserProfile {
|
|
|
27
27
|
* Upload avatar image for the current authenticated user. Accepts
|
|
28
28
|
* multipart/form-data with 'avatar' field.
|
|
29
29
|
*/
|
|
30
|
-
async accountsProfileAvatarCreate(data:
|
|
31
|
-
const
|
|
30
|
+
async accountsProfileAvatarCreate(data: Models.CfgAccountsProfileAvatarCreateRequest): Promise<Models.User> {
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append('avatar', data.avatar);
|
|
33
|
+
const response = await this.client.request('POST', "/cfg/accounts/profile/avatar/", { formData });
|
|
32
34
|
return response;
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -25,11 +25,19 @@ export interface User {
|
|
|
25
25
|
is_superuser: boolean;
|
|
26
26
|
date_joined: string;
|
|
27
27
|
last_login?: string | null;
|
|
28
|
-
/** Get count of unanswered messages for the user. */
|
|
29
28
|
unanswered_messages_count: number;
|
|
30
29
|
centrifugo: CentrifugoToken | null;
|
|
31
30
|
}
|
|
32
31
|
|
|
32
|
+
/**
|
|
33
|
+
*
|
|
34
|
+
* Request model (no read-only fields).
|
|
35
|
+
*/
|
|
36
|
+
export interface CfgAccountsProfileAvatarCreateRequest {
|
|
37
|
+
/** Avatar image file (JPEG, PNG, GIF, WebP, max 5MB) */
|
|
38
|
+
avatar: File | Blob;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
/**
|
|
34
42
|
* Serializer for updating user profile.
|
|
35
43
|
*
|
|
@@ -28,6 +28,7 @@ export class APIClient {
|
|
|
28
28
|
private httpClient: HttpClientAdapter;
|
|
29
29
|
private logger: APILogger | null = null;
|
|
30
30
|
private retryConfig: RetryConfig | null = null;
|
|
31
|
+
private tokenGetter: (() => string | null) | null = null;
|
|
31
32
|
|
|
32
33
|
// Sub-clients
|
|
33
34
|
public auth: Auth;
|
|
@@ -41,10 +42,12 @@ export class APIClient {
|
|
|
41
42
|
httpClient?: HttpClientAdapter;
|
|
42
43
|
loggerConfig?: Partial<LoggerConfig>;
|
|
43
44
|
retryConfig?: RetryConfig;
|
|
45
|
+
tokenGetter?: () => string | null;
|
|
44
46
|
}
|
|
45
47
|
) {
|
|
46
48
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
47
49
|
this.httpClient = options?.httpClient || new FetchAdapter();
|
|
50
|
+
this.tokenGetter = options?.tokenGetter || null;
|
|
48
51
|
|
|
49
52
|
// Initialize logger if config provided
|
|
50
53
|
if (options?.loggerConfig !== undefined) {
|
|
@@ -78,6 +81,21 @@ export class APIClient {
|
|
|
78
81
|
return null;
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Get the base URL for building streaming/download URLs.
|
|
86
|
+
*/
|
|
87
|
+
getBaseUrl(): string {
|
|
88
|
+
return this.baseUrl;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get JWT token for URL authentication (used in streaming endpoints).
|
|
93
|
+
* Returns null if no token getter is configured or no token is available.
|
|
94
|
+
*/
|
|
95
|
+
getToken(): string | null {
|
|
96
|
+
return this.tokenGetter ? this.tokenGetter() : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
/**
|
|
82
100
|
* Make HTTP request with Django CSRF and session handling.
|
|
83
101
|
* Automatically retries on network errors and 5xx server errors.
|