@djangocfg/api 2.1.542 → 2.1.544
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 +1 -0
- package/dist/auth-server.cjs.map +1 -1
- package/dist/auth.cjs +57 -59
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +134 -7
- package/dist/auth.d.ts +134 -7
- package/dist/auth.mjs +69 -72
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +1 -0
- package/dist/clients.cjs.map +1 -1
- package/dist/hooks.cjs +1 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/package.json +3 -2
- package/src/auth/__tests__/useTwoFactorStatus.test.ts +2 -2
- package/src/auth/context/AccountsContext.tsx +15 -11
- package/src/auth/hooks/useAuthForm.ts +0 -1
- package/src/auth/hooks/useCfgRouter.ts +1 -4
- package/src/auth/hooks/useGithubAuth.ts +25 -3
- package/src/auth/hooks/useLocalStorage.ts +7 -30
- package/src/auth/hooks/useSessionStorage.ts +6 -29
- package/src/auth/hooks/useTwoFactorVerify.ts +5 -9
- package/src/auth/utils/analytics.ts +22 -2
- package/src/auth/utils/errors.ts +22 -11
- package/src/auth/utils/storage.ts +64 -0
package/dist/index.d.cts
CHANGED
|
@@ -2297,8 +2297,8 @@ declare class APIError extends Error {
|
|
|
2297
2297
|
/** Network Error (connection failed, timeout, etc.) */
|
|
2298
2298
|
declare class NetworkError extends Error {
|
|
2299
2299
|
url: string;
|
|
2300
|
-
originalError?: Error;
|
|
2301
|
-
constructor(message: string, url: string, originalError?: Error);
|
|
2300
|
+
originalError?: Error | undefined;
|
|
2301
|
+
constructor(message: string, url: string, originalError?: Error | undefined);
|
|
2302
2302
|
}
|
|
2303
2303
|
|
|
2304
2304
|
interface ValidationErrorDetail {
|
package/dist/index.d.ts
CHANGED
|
@@ -2297,8 +2297,8 @@ declare class APIError extends Error {
|
|
|
2297
2297
|
/** Network Error (connection failed, timeout, etc.) */
|
|
2298
2298
|
declare class NetworkError extends Error {
|
|
2299
2299
|
url: string;
|
|
2300
|
-
originalError?: Error;
|
|
2301
|
-
constructor(message: string, url: string, originalError?: Error);
|
|
2300
|
+
originalError?: Error | undefined;
|
|
2301
|
+
constructor(message: string, url: string, originalError?: Error | undefined);
|
|
2302
2302
|
}
|
|
2303
2303
|
|
|
2304
2304
|
interface ValidationErrorDetail {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.544",
|
|
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",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"build": "tsup",
|
|
72
72
|
"clean": "rm -rf dist",
|
|
73
73
|
"dev": "tsup --watch",
|
|
74
|
+
"lint": "eslint .",
|
|
74
75
|
"check": "tsc --noEmit",
|
|
75
76
|
"test": "vitest run",
|
|
76
77
|
"test:watch": "vitest"
|
|
@@ -84,7 +85,7 @@
|
|
|
84
85
|
"zod": "^4.3.6"
|
|
85
86
|
},
|
|
86
87
|
"devDependencies": {
|
|
87
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
88
|
+
"@djangocfg/typescript-config": "^2.1.544",
|
|
88
89
|
"@testing-library/react": "^16.3.2",
|
|
89
90
|
"@types/node": "^25.9.5",
|
|
90
91
|
"@types/react": "19.2.15",
|
|
@@ -21,7 +21,7 @@ const mockDisableCreate = vi.fn();
|
|
|
21
21
|
vi.mock('../../_api/generated/sdk.gen', () => ({
|
|
22
22
|
CfgTotp: {
|
|
23
23
|
cfgTotpDevicesRetrieve: () => mockDevicesRetrieve(),
|
|
24
|
-
cfgTotpDisableCreate: (args:
|
|
24
|
+
cfgTotpDisableCreate: (args: unknown) => mockDisableCreate(args),
|
|
25
25
|
},
|
|
26
26
|
}));
|
|
27
27
|
|
|
@@ -117,7 +117,7 @@ describe('useTwoFactorStatus', () => {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
it('should set loading state during fetch', async () => {
|
|
120
|
-
let resolvePromise: (value:
|
|
120
|
+
let resolvePromise: (value: unknown) => void;
|
|
121
121
|
mockDevicesRetrieve.mockReturnValue(
|
|
122
122
|
new Promise((resolve) => {
|
|
123
123
|
resolvePromise = resolve;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"use client";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
createContext,
|
|
17
|
+
createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState
|
|
18
18
|
} from 'react';
|
|
19
19
|
|
|
20
20
|
import { auth } from '../../_api/generated/helpers/auth';
|
|
@@ -150,30 +150,34 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
150
150
|
}
|
|
151
151
|
}, []);
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
// These five are `useCallback`ed because they are dependencies of the context
|
|
154
|
+
// `useMemo` below. Rebuilt each render, they made that memo return a new object
|
|
155
|
+
// every time — so every consumer of this root provider re-rendered on every
|
|
156
|
+
// render of it, and the memo bought nothing. SWR guarantees a stable `trigger`.
|
|
157
|
+
const updateProfile = useCallback(async (data: CfgUserUpdateRequest): Promise<User> => {
|
|
154
158
|
await triggerProfileUpdate({ body: data });
|
|
155
159
|
const user = await refreshProfile({ callerId: 'updateProfile', force: true });
|
|
156
160
|
return user as User;
|
|
157
|
-
};
|
|
161
|
+
}, [triggerProfileUpdate, refreshProfile]);
|
|
158
162
|
|
|
159
|
-
const partialUpdateProfile = async (data: PatchedCfgUserUpdateRequest): Promise<User> => {
|
|
163
|
+
const partialUpdateProfile = useCallback(async (data: PatchedCfgUserUpdateRequest): Promise<User> => {
|
|
160
164
|
await triggerProfilePartialUpdate({ body: data });
|
|
161
165
|
const user = await refreshProfile({ callerId: 'partialUpdateProfile', force: true });
|
|
162
166
|
return user as User;
|
|
163
|
-
};
|
|
167
|
+
}, [triggerProfilePartialUpdate, refreshProfile]);
|
|
164
168
|
|
|
165
|
-
const uploadAvatar = async (avatar: File | Blob): Promise<User> => {
|
|
169
|
+
const uploadAvatar = useCallback(async (avatar: File | Blob): Promise<User> => {
|
|
166
170
|
await triggerAvatar({ body: { avatar } });
|
|
167
171
|
const user = await refreshProfile({ callerId: 'uploadAvatar', force: true });
|
|
168
172
|
return user as User;
|
|
169
|
-
};
|
|
173
|
+
}, [triggerAvatar, refreshProfile]);
|
|
170
174
|
|
|
171
|
-
const requestOTP = async (data: OtpRequestRequest): Promise<OtpRequestResponse> => {
|
|
175
|
+
const requestOTP = useCallback(async (data: OtpRequestRequest): Promise<OtpRequestResponse> => {
|
|
172
176
|
const result = await triggerOtpRequest({ body: data });
|
|
173
177
|
return result as OtpRequestResponse;
|
|
174
|
-
};
|
|
178
|
+
}, [triggerOtpRequest]);
|
|
175
179
|
|
|
176
|
-
const verifyOTP = async (data: OtpVerifyRequest): Promise<OtpVerifyResponse> => {
|
|
180
|
+
const verifyOTP = useCallback(async (data: OtpVerifyRequest): Promise<OtpVerifyResponse> => {
|
|
177
181
|
const result = (await triggerOtpVerify({ body: data })) as OtpVerifyResponse;
|
|
178
182
|
|
|
179
183
|
if (result.requires_2fa && result.session_id) {
|
|
@@ -197,7 +201,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
return result;
|
|
200
|
-
};
|
|
204
|
+
}, [triggerOtpVerify, refreshProfile]);
|
|
201
205
|
|
|
202
206
|
const logout = useCallback(() => {
|
|
203
207
|
// Best-effort server-side revocation: without blacklisting, a stolen
|
|
@@ -63,11 +63,8 @@ function withBasePath(path: string, basePath: string): string {
|
|
|
63
63
|
export function useCfgRouter() {
|
|
64
64
|
const router = useNextRouter();
|
|
65
65
|
|
|
66
|
-
// Get basePath
|
|
66
|
+
// Get basePath
|
|
67
67
|
const basePath = useMemo(() => getBasePath(), []);
|
|
68
|
-
const isStaticBuild = useMemo(() => {
|
|
69
|
-
return typeof process !== 'undefined' && process.env.NEXT_PUBLIC_STATIC_BUILD === 'true';
|
|
70
|
-
}, []);
|
|
71
68
|
|
|
72
69
|
const push = useCallback((href: string, options?: { scroll?: boolean }) => {
|
|
73
70
|
if (basePath) {
|
|
@@ -4,15 +4,31 @@ import { useCallback, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { auth } from '../../_api/generated/helpers/auth';
|
|
6
6
|
import { CfgAccountsOauth } from '../../_api/generated/sdk.gen';
|
|
7
|
+
import type { OAuthTokenResponse } from '../../_api/generated/types.gen';
|
|
7
8
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
8
9
|
import { authLogger } from '../utils/logger';
|
|
9
10
|
import { useCfgRouter } from './useCfgRouter';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The user payload as the OAuth endpoint declares it.
|
|
14
|
+
*
|
|
15
|
+
* Derived from the generated response rather than restated, so a schema change
|
|
16
|
+
* reaches callers through `make gen` instead of drifting silently. Today the
|
|
17
|
+
* backend types it as a free-form object; when it names a real user model, this
|
|
18
|
+
* narrows on its own.
|
|
19
|
+
*/
|
|
20
|
+
export type OAuthUser = NonNullable<OAuthTokenResponse['user']>;
|
|
21
|
+
|
|
11
22
|
export interface UseGithubAuthOptions {
|
|
12
23
|
sourceUrl?: string;
|
|
13
24
|
/** Keep the completed browser session for 30 days. */
|
|
14
25
|
rememberMe?: boolean;
|
|
15
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The OAuth endpoint's OpenAPI schema types `user` as a free-form object, so
|
|
28
|
+
* this is what the server actually promises — not a `User`. Narrow it at the
|
|
29
|
+
* call site; typing it tighter here would be an assertion, not a fact.
|
|
30
|
+
*/
|
|
31
|
+
onSuccess?: (user: OAuthUser, isNewUser: boolean) => void;
|
|
16
32
|
onError?: (error: string) => void;
|
|
17
33
|
/** Callback when 2FA is required */
|
|
18
34
|
onRequires2FA?: (sessionId: string, shouldPrompt2FA: boolean) => void;
|
|
@@ -184,8 +200,14 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
|
|
|
184
200
|
Analytics.setUser(String(response.user.id));
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
// Call success callback
|
|
188
|
-
|
|
203
|
+
// Call success callback.
|
|
204
|
+
//
|
|
205
|
+
// Guarded because the schema allows `user: null` — the 2FA branch returns
|
|
206
|
+
// no user — and five lines up this same value is read through `?.`. The
|
|
207
|
+
// old `any` let one line be careful and the next not.
|
|
208
|
+
if (response.user) {
|
|
209
|
+
onSuccess?.(response.user, response.is_new_user || false);
|
|
210
|
+
}
|
|
189
211
|
|
|
190
212
|
// Redirect (unless skipRedirect is true)
|
|
191
213
|
if (!skipRedirect) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { authLogger } from '../utils/logger';
|
|
6
|
+
import { isQuotaExceededError, isWithinSizeLimit } from '../utils/storage';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Simple localStorage hook with better error handling
|
|
@@ -36,26 +37,6 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
36
37
|
}
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
// Check data size and limit
|
|
40
|
-
const checkDataSize = (data: any): boolean => {
|
|
41
|
-
try {
|
|
42
|
-
const jsonString = JSON.stringify(data);
|
|
43
|
-
const sizeInBytes = new Blob([jsonString]).size;
|
|
44
|
-
const sizeInKB = sizeInBytes / 1024;
|
|
45
|
-
|
|
46
|
-
// Limit to 1MB per item
|
|
47
|
-
if (sizeInKB > 1024) {
|
|
48
|
-
authLogger.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return true;
|
|
53
|
-
} catch (error) {
|
|
54
|
-
authLogger.error(`Error checking data size for key "${key}":`, error);
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
40
|
// Clear old data when localStorage is full
|
|
60
41
|
const clearOldData = () => {
|
|
61
42
|
try {
|
|
@@ -102,7 +83,7 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
102
83
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
103
84
|
|
|
104
85
|
// Check data size before attempting to save
|
|
105
|
-
if (!
|
|
86
|
+
if (!isWithinSizeLimit(valueToStore, key)) {
|
|
106
87
|
authLogger.warn(`Data size too large for key "${key}", removing key`);
|
|
107
88
|
// Remove the key if data is too large
|
|
108
89
|
try {
|
|
@@ -129,11 +110,9 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
129
110
|
}
|
|
130
111
|
// Add timestamp for cleanup
|
|
131
112
|
window.localStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
132
|
-
} catch (storageError:
|
|
113
|
+
} catch (storageError: unknown) {
|
|
133
114
|
// If quota exceeded, clear old data and try again
|
|
134
|
-
if (storageError
|
|
135
|
-
storageError.code === 22 ||
|
|
136
|
-
storageError.message?.includes('quota')) {
|
|
115
|
+
if (isQuotaExceededError(storageError)) {
|
|
137
116
|
authLogger.warn('localStorage quota exceeded, clearing old data...');
|
|
138
117
|
clearOldData();
|
|
139
118
|
|
|
@@ -185,11 +164,9 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
|
185
164
|
try {
|
|
186
165
|
window.localStorage.removeItem(key);
|
|
187
166
|
window.localStorage.removeItem(`${key}_timestamp`);
|
|
188
|
-
} catch (removeError:
|
|
167
|
+
} catch (removeError: unknown) {
|
|
189
168
|
// If removal fails due to quota, try to clear some data first
|
|
190
|
-
if (removeError
|
|
191
|
-
removeError.code === 22 ||
|
|
192
|
-
removeError.message?.includes('quota')) {
|
|
169
|
+
if (isQuotaExceededError(removeError)) {
|
|
193
170
|
authLogger.warn('localStorage quota exceeded during removal, clearing old data...');
|
|
194
171
|
clearOldData();
|
|
195
172
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { authLogger } from '../utils/logger';
|
|
6
|
+
import { isQuotaExceededError, isWithinSizeLimit } from '../utils/storage';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Simple sessionStorage hook with better error handling
|
|
@@ -26,26 +27,6 @@ export function useSessionStorage<T>(key: string, initialValue: T) {
|
|
|
26
27
|
}
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
// Check data size and limit
|
|
30
|
-
const checkDataSize = (data: any): boolean => {
|
|
31
|
-
try {
|
|
32
|
-
const jsonString = JSON.stringify(data);
|
|
33
|
-
const sizeInBytes = new Blob([jsonString]).size;
|
|
34
|
-
const sizeInKB = sizeInBytes / 1024;
|
|
35
|
-
|
|
36
|
-
// Limit to 1MB per item
|
|
37
|
-
if (sizeInKB > 1024) {
|
|
38
|
-
authLogger.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return true;
|
|
43
|
-
} catch (error) {
|
|
44
|
-
authLogger.error(`Error checking data size for key "${key}":`, error);
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
30
|
// Clear old data when sessionStorage is full
|
|
50
31
|
const clearOldData = () => {
|
|
51
32
|
try {
|
|
@@ -92,7 +73,7 @@ export function useSessionStorage<T>(key: string, initialValue: T) {
|
|
|
92
73
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
93
74
|
|
|
94
75
|
// Check data size before attempting to save
|
|
95
|
-
if (!
|
|
76
|
+
if (!isWithinSizeLimit(valueToStore, key)) {
|
|
96
77
|
authLogger.warn(`Data size too large for key "${key}", removing key`);
|
|
97
78
|
// Remove the key if data is too large
|
|
98
79
|
try {
|
|
@@ -114,11 +95,9 @@ export function useSessionStorage<T>(key: string, initialValue: T) {
|
|
|
114
95
|
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
|
|
115
96
|
// Add timestamp for cleanup
|
|
116
97
|
window.sessionStorage.setItem(`${key}_timestamp`, Date.now().toString());
|
|
117
|
-
} catch (storageError:
|
|
98
|
+
} catch (storageError: unknown) {
|
|
118
99
|
// If quota exceeded, clear old data and try again
|
|
119
|
-
if (storageError
|
|
120
|
-
storageError.code === 22 ||
|
|
121
|
-
storageError.message?.includes('quota')) {
|
|
100
|
+
if (isQuotaExceededError(storageError)) {
|
|
122
101
|
authLogger.warn('sessionStorage quota exceeded, clearing old data...');
|
|
123
102
|
clearOldData();
|
|
124
103
|
|
|
@@ -160,11 +139,9 @@ export function useSessionStorage<T>(key: string, initialValue: T) {
|
|
|
160
139
|
try {
|
|
161
140
|
window.sessionStorage.removeItem(key);
|
|
162
141
|
window.sessionStorage.removeItem(`${key}_timestamp`);
|
|
163
|
-
} catch (removeError:
|
|
142
|
+
} catch (removeError: unknown) {
|
|
164
143
|
// If removal fails due to quota, try to clear some data first
|
|
165
|
-
if (removeError
|
|
166
|
-
removeError.code === 22 ||
|
|
167
|
-
removeError.message?.includes('quota')) {
|
|
144
|
+
if (isQuotaExceededError(removeError)) {
|
|
168
145
|
authLogger.warn('sessionStorage quota exceeded during removal, clearing old data...');
|
|
169
146
|
clearOldData();
|
|
170
147
|
|
|
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { auth } from '../../_api/generated/helpers/auth';
|
|
6
6
|
import { CfgTotpVerify } from '../../_api/generated/sdk.gen';
|
|
7
|
+
import type { TotpVerifyUser, VerifyResponse } from '../../_api/generated/types.gen';
|
|
7
8
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
8
9
|
import { authLogger } from '../utils/logger';
|
|
9
10
|
import { consumeSavedRedirect } from './useAuthRedirect';
|
|
@@ -12,7 +13,7 @@ import { extractAttemptsRemaining, extractTwoFactorError, isValidTotpCode } from
|
|
|
12
13
|
|
|
13
14
|
export interface UseTwoFactorVerifyOptions {
|
|
14
15
|
/** Callback on successful 2FA verification */
|
|
15
|
-
onSuccess?: (user:
|
|
16
|
+
onSuccess?: (user: TotpVerifyUser) => void;
|
|
16
17
|
/** Callback on error */
|
|
17
18
|
onError?: (error: string) => void;
|
|
18
19
|
/** URL to redirect after successful verification */
|
|
@@ -63,14 +64,9 @@ export const useTwoFactorVerify = (
|
|
|
63
64
|
setError(null);
|
|
64
65
|
}, []);
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
user: any;
|
|
70
|
-
warning?: string;
|
|
71
|
-
remaining_backup_codes?: number;
|
|
72
|
-
persistent_session: boolean;
|
|
73
|
-
}) => {
|
|
67
|
+
// `VerifyResponse` is the generated shape of this endpoint, so the hand-written
|
|
68
|
+
// copy it replaces cannot drift from the server contract any more.
|
|
69
|
+
const handleSuccess = useCallback((response: VerifyResponse) => {
|
|
74
70
|
// Save tokens — the ONE write path (atomic pair + session notification).
|
|
75
71
|
// Storage is always localStorage; session length is the server's call via
|
|
76
72
|
// `remember_me`. See AccountsContext.verifyOTP.
|
|
@@ -34,16 +34,36 @@ export enum AnalyticsCategory {
|
|
|
34
34
|
export type AnalyticsEventType = `${AnalyticsEvent}`;
|
|
35
35
|
export type AnalyticsCategoryType = `${AnalyticsCategory}`;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* What an analytics backend can actually carry.
|
|
39
|
+
*
|
|
40
|
+
* GA4 and OpenPanel serialise a property to a scalar; an object or a function
|
|
41
|
+
* arrives as "[object Object]" without erroring anywhere. Declaring the wider
|
|
42
|
+
* `unknown` here promised a forwarding guarantee no implementation keeps, and
|
|
43
|
+
* it made this sink incompatible with `AnalyticsEventProperties` in
|
|
44
|
+
* `@djangocfg/analytics` — the two describe the same channel and must agree.
|
|
45
|
+
* Kept as a local declaration rather than an import: `api` does not depend on
|
|
46
|
+
* `analytics`, and one shared four-member union is not worth that edge.
|
|
47
|
+
*/
|
|
48
|
+
export type AnalyticsPropertyValue = string | number | boolean | null;
|
|
49
|
+
|
|
37
50
|
interface AnalyticsEventParams {
|
|
38
51
|
category?: AnalyticsCategoryType;
|
|
39
52
|
label?: string;
|
|
40
53
|
value?: number;
|
|
41
|
-
|
|
54
|
+
// Extra dimensions vary per event; the sink only forwards them.
|
|
55
|
+
[key: string]: AnalyticsPropertyValue | undefined;
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
/** What an analytics implementation must provide to receive auth events. */
|
|
45
59
|
export interface AnalyticsSink {
|
|
46
|
-
|
|
60
|
+
// `| undefined` in the value: an optional dimension that was not supplied
|
|
61
|
+
// reaches the sink as an explicit `undefined`, which every backend already
|
|
62
|
+
// treats as absent. Excluding it would reject the caller below.
|
|
63
|
+
event(
|
|
64
|
+
name: string,
|
|
65
|
+
params?: Record<string, AnalyticsPropertyValue | undefined>,
|
|
66
|
+
): void;
|
|
47
67
|
setUser(userId: string | null): void;
|
|
48
68
|
}
|
|
49
69
|
|
package/src/auth/utils/errors.ts
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
|
+
/** Read a property off an unknown value without asserting its shape. */
|
|
2
|
+
const prop = (value: unknown, key: string): unknown =>
|
|
3
|
+
typeof value === 'object' && value !== null
|
|
4
|
+
? (value as Record<string, unknown>)[key]
|
|
5
|
+
: undefined;
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
|
-
* Format authentication error for display
|
|
8
|
+
* Format authentication error for display.
|
|
9
|
+
*
|
|
10
|
+
* Each candidate is checked to be a string before it is returned. Under the
|
|
11
|
+
* previous `any` the check was only that the property was truthy, so a DRF
|
|
12
|
+
* field-error object (`{detail: {email: [...]}}`) reached the UI as
|
|
13
|
+
* "[object Object]" — an error message that tells the user nothing.
|
|
3
14
|
*/
|
|
4
|
-
export const formatAuthError = (error:
|
|
15
|
+
export const formatAuthError = (error: unknown): string => {
|
|
5
16
|
if (typeof error === 'string') {
|
|
6
17
|
return error;
|
|
7
18
|
}
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return error.detail;
|
|
15
|
-
}
|
|
20
|
+
const candidates = [
|
|
21
|
+
prop(error, 'message'),
|
|
22
|
+
prop(error, 'detail'),
|
|
23
|
+
prop(prop(prop(error, 'response'), 'data'), 'detail'),
|
|
24
|
+
];
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
if (typeof candidate === 'string' && candidate) {
|
|
28
|
+
return candidate;
|
|
29
|
+
}
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
return 'An unexpected error occurred';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Storage helpers shared by `useLocalStorage` and `useSessionStorage`.
|
|
3
|
+
*
|
|
4
|
+
* Both hooks carried byte-identical copies of `checkDataSize`, and the quota
|
|
5
|
+
* check was written out four times — twice per hook, on write and on remove.
|
|
6
|
+
* Every copy typed its caught error `any` and then read three properties off
|
|
7
|
+
* it, which is exactly the read `unknown` makes you justify.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { authLogger } from './logger';
|
|
11
|
+
|
|
12
|
+
/** Per-item ceiling. Browsers cap the whole origin near 5MB. */
|
|
13
|
+
const MAX_ITEM_KB = 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether a caught error means the storage quota is full.
|
|
17
|
+
*
|
|
18
|
+
* Browsers disagree on how to say it: modern engines throw a `DOMException`
|
|
19
|
+
* named `QuotaExceededError`, older WebKit used code 22, and some report it
|
|
20
|
+
* only in the message. All three are checked because all three are still met
|
|
21
|
+
* in the wild — and because the caller's response (drop old data, retry) is
|
|
22
|
+
* the same for each.
|
|
23
|
+
*/
|
|
24
|
+
export function isQuotaExceededError(error: unknown): boolean {
|
|
25
|
+
if (typeof error !== 'object' || error === null) return false;
|
|
26
|
+
|
|
27
|
+
const { name, code, message } = error as {
|
|
28
|
+
name?: unknown;
|
|
29
|
+
code?: unknown;
|
|
30
|
+
message?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
name === 'QuotaExceededError' ||
|
|
35
|
+
code === 22 ||
|
|
36
|
+
(typeof message === 'string' && message.includes('quota'))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether `data` serialises to something small enough to store.
|
|
42
|
+
*
|
|
43
|
+
* `unknown` rather than a type parameter: this only serialises, so it needs no
|
|
44
|
+
* knowledge of the shape, and a parameter would invite callers to read one.
|
|
45
|
+
* Returns false on a value that cannot be serialised at all — a circular
|
|
46
|
+
* reference is not storable either, and the caller's response is identical.
|
|
47
|
+
*/
|
|
48
|
+
export function isWithinSizeLimit(data: unknown, key: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
const sizeInKB = new Blob([JSON.stringify(data)]).size / 1024;
|
|
51
|
+
|
|
52
|
+
if (sizeInKB > MAX_ITEM_KB) {
|
|
53
|
+
authLogger.warn(
|
|
54
|
+
`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`,
|
|
55
|
+
);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
authLogger.error(`Error checking data size for key "${key}":`, error);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|