@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/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.542",
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.542",
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: any) => mockDisableCreate(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: any) => void;
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, memo, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState
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
- const updateProfile = async (data: CfgUserUpdateRequest): Promise<User> => {
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
@@ -51,7 +51,6 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
51
51
  const {
52
52
  identifier,
53
53
  otp,
54
- isLoading,
55
54
  acceptedTerms,
56
55
  marketingConsent,
57
56
  rememberMe,
@@ -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 and check if we're in static build mode
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
- onSuccess?: (user: any, isNewUser: boolean) => void;
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
- onSuccess?.(response.user, response.is_new_user || false);
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 { useEffect, useState } from 'react';
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 (!checkDataSize(valueToStore)) {
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: any) {
113
+ } catch (storageError: unknown) {
133
114
  // If quota exceeded, clear old data and try again
134
- if (storageError.name === 'QuotaExceededError' ||
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: any) {
167
+ } catch (removeError: unknown) {
189
168
  // If removal fails due to quota, try to clear some data first
190
- if (removeError.name === 'QuotaExceededError' ||
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 (!checkDataSize(valueToStore)) {
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: any) {
98
+ } catch (storageError: unknown) {
118
99
  // If quota exceeded, clear old data and try again
119
- if (storageError.name === 'QuotaExceededError' ||
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: any) {
142
+ } catch (removeError: unknown) {
164
143
  // If removal fails due to quota, try to clear some data first
165
- if (removeError.name === 'QuotaExceededError' ||
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: any) => void;
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
- const handleSuccess = useCallback((response: {
67
- access_token: string;
68
- refresh_token: string;
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
- [key: string]: any;
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
- event(name: string, params?: Record<string, any>): void;
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
 
@@ -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: any): string => {
15
+ export const formatAuthError = (error: unknown): string => {
5
16
  if (typeof error === 'string') {
6
17
  return error;
7
18
  }
8
19
 
9
- if (error?.message) {
10
- return error.message;
11
- }
12
-
13
- if (error?.detail) {
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
- if (error?.response?.data?.detail) {
18
- return error.response.data.detail;
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
+ }