@djangocfg/api 2.1.567 → 2.1.570

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.567",
3
+ "version": "2.1.570",
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",
@@ -85,7 +85,7 @@
85
85
  "zod": "^4.3.6"
86
86
  },
87
87
  "devDependencies": {
88
- "@djangocfg/typescript-config": "^2.1.567",
88
+ "@djangocfg/typescript-config": "^2.1.570",
89
89
  "@testing-library/react": "^16.3.2",
90
90
  "@types/node": "^25.9.5",
91
91
  "@types/react": "19.2.15",
@@ -1,15 +1,15 @@
1
1
  /**
2
- * formatAuthError tests (auth/utils/errors.ts).
2
+ * Auth error resolution tests (auth/utils/errors.ts).
3
3
  *
4
4
  * Turns whatever the auth layer throws (string, Error, DRF `{detail}`, axios-ish
5
- * nested `response.data.detail`) into a user-facing string, with a stable
6
- * fallback. The precedence order matters: message > detail > nested detail, and
7
- * it must never throw on null/odd shapes.
5
+ * nested `response.data.detail`, an `APIError`-shaped object) into a user-facing
6
+ * string, with a stable fallback. The precedence order matters: server prose >
7
+ * status-code copy > fallback, and it must never throw on null/odd shapes.
8
8
  */
9
9
 
10
10
  import { describe, expect, it } from 'vitest';
11
11
 
12
- import { formatAuthError } from '../utils/errors';
12
+ import { AUTH_ERROR_KEYS, formatAuthError, resolveAuthError } from '../utils/errors';
13
13
 
14
14
  describe('formatAuthError', () => {
15
15
  it('returns a plain string as-is', () => {
@@ -39,10 +39,74 @@ describe('formatAuthError', () => {
39
39
  });
40
40
 
41
41
  it('falls back for null / undefined / empty object', () => {
42
- const FALLBACK = 'An unexpected error occurred';
42
+ const FALLBACK = 'Something went wrong';
43
43
  expect(formatAuthError(null)).toBe(FALLBACK);
44
44
  expect(formatAuthError(undefined)).toBe(FALLBACK);
45
45
  expect(formatAuthError({})).toBe(FALLBACK);
46
46
  expect(formatAuthError(42)).toBe(FALLBACK);
47
47
  });
48
+
49
+ it("prefers the caller's fallback when nothing else identifies the failure", () => {
50
+ expect(formatAuthError({}, 'Failed to send OTP')).toBe('Failed to send OTP');
51
+ });
52
+ });
53
+
54
+ /*
55
+ * The regression this module exists for: a bare `HTTP 404:` under a sign-in
56
+ * field. `APIError` builds that string itself when the response carries no
57
+ * message, and `statusText` is empty under HTTP/2 — so it is a status line
58
+ * wearing a message's clothes, and every extractor used to hand it straight to
59
+ * the UI.
60
+ */
61
+ describe('resolveAuthError — transport strings never reach the UI', () => {
62
+ it('rejects the bare HTTP fallback and explains the status instead', () => {
63
+ const err = { message: 'HTTP 404: ', statusCode: 404, name: 'APIError' };
64
+ const { message, key } = resolveAuthError(err);
65
+
66
+ expect(message).not.toMatch(/HTTP/);
67
+ expect(key).toBe(AUTH_ERROR_KEYS.serverUnavailable);
68
+ });
69
+
70
+ it('rejects it even when statusText filled it in', () => {
71
+ expect(formatAuthError({ message: 'HTTP 500: Internal Server Error', statusCode: 500 }))
72
+ .not.toMatch(/HTTP/);
73
+ });
74
+
75
+ it("outranks the caller's fallback with status-derived copy", () => {
76
+ // A 404 reported as "Failed to send the code" blames the code, not the
77
+ // endpoint that is missing.
78
+ const { key } = resolveAuthError({ message: 'HTTP 404: ', statusCode: 404 }, 'Failed to send OTP');
79
+ expect(key).toBe(AUTH_ERROR_KEYS.serverUnavailable);
80
+ });
81
+ });
82
+
83
+ describe('resolveAuthError — status codes', () => {
84
+ it.each([
85
+ [401, AUTH_ERROR_KEYS.invalidCredentials],
86
+ [403, AUTH_ERROR_KEYS.invalidCredentials],
87
+ [404, AUTH_ERROR_KEYS.serverUnavailable],
88
+ [429, AUTH_ERROR_KEYS.rateLimited],
89
+ [500, AUTH_ERROR_KEYS.serverUnavailable],
90
+ [503, AUTH_ERROR_KEYS.serverUnavailable],
91
+ ])('maps %i to its key', (statusCode, expected) => {
92
+ expect(resolveAuthError({ statusCode }).key).toBe(expected);
93
+ });
94
+
95
+ it('reads a Response-shaped `status` too', () => {
96
+ expect(resolveAuthError({ status: 429 }).key).toBe(AUTH_ERROR_KEYS.rateLimited);
97
+ });
98
+
99
+ it('server prose outranks the status code', () => {
100
+ const err = { response: { detail: 'This code has expired' }, statusCode: 400 };
101
+ const { message, key } = resolveAuthError(err);
102
+
103
+ expect(message).toBe('This code has expired');
104
+ // No key: the backend already chose the words, in the caller's locale.
105
+ expect(key).toBeUndefined();
106
+ });
107
+
108
+ it('reads the APIError body spellings the auth endpoints actually use', () => {
109
+ expect(formatAuthError({ response: { error: 'Too many attempts' } })).toBe('Too many attempts');
110
+ expect(formatAuthError({ response: { message: 'Account locked' } })).toBe('Account locked');
111
+ });
48
112
  });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { logAuthFailure } from '../utils/errors';
4
+
5
+ /**
6
+ * A mistyped OTP must not page anyone.
7
+ *
8
+ * `@djangocfg/devtools` captures `console.error` as an ERROR event, so every
9
+ * error-level auth log became a production alert. A customer fat-fingering
10
+ * their one-time code raised "❌ Error" in Telegram — with consola's `%c` CSS
11
+ * in the message body, because the capture takes the console string verbatim.
12
+ * Real faults are unfindable in that.
13
+ */
14
+
15
+ const sink = () => ({ warn: vi.fn(), error: vi.fn() });
16
+
17
+ describe('logAuthFailure', () => {
18
+ describe('the user got it wrong — warn, no alert', () => {
19
+ it.each([400, 401, 403, 404, 429, 499])('treats %i as a warning', (statusCode) => {
20
+ const log = sink();
21
+ logAuthFailure(log, 'Verify OTP failed:', { statusCode });
22
+ expect(log.warn).toHaveBeenCalledOnce();
23
+ expect(log.error).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it('reads `status` too, for a rejection that skipped the interceptor', () => {
27
+ const log = sink();
28
+ logAuthFailure(log, 'Verify OTP failed:', { status: 401 });
29
+ expect(log.warn).toHaveBeenCalledOnce();
30
+ expect(log.error).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('passes the message and the error through unchanged', () => {
34
+ const log = sink();
35
+ const error = { statusCode: 401 };
36
+ logAuthFailure(log, 'Verify OTP failed:', error);
37
+ expect(log.warn).toHaveBeenCalledWith('Verify OTP failed:', error);
38
+ });
39
+ });
40
+
41
+ describe('something is actually broken — error, raise the alert', () => {
42
+ it.each([500, 502, 503, 504])('treats %i as an error', (statusCode) => {
43
+ const log = sink();
44
+ logAuthFailure(log, 'Verify OTP failed:', { statusCode });
45
+ expect(log.error).toHaveBeenCalledOnce();
46
+ expect(log.warn).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it('keeps a network failure at error — it carries no status at all', () => {
50
+ const log = sink();
51
+ logAuthFailure(log, 'Verify OTP failed:', new TypeError('Failed to fetch'));
52
+ expect(log.error).toHaveBeenCalledOnce();
53
+ expect(log.warn).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it.each([null, undefined, 'a string', 42, { statusCode: 'nonsense' }])(
57
+ 'keeps an unreadable error (%s) at error rather than silencing it',
58
+ (error) => {
59
+ const log = sink();
60
+ logAuthFailure(log, 'Verify OTP failed:', error);
61
+ expect(log.error).toHaveBeenCalledOnce();
62
+ expect(log.warn).not.toHaveBeenCalled();
63
+ },
64
+ );
65
+ });
66
+
67
+ describe('the boundaries', () => {
68
+ it('399 is not a client error', () => {
69
+ const log = sink();
70
+ logAuthFailure(log, 'x', { statusCode: 399 });
71
+ expect(log.error).toHaveBeenCalledOnce();
72
+ });
73
+
74
+ it('500 is not a client error', () => {
75
+ const log = sink();
76
+ logAuthFailure(log, 'x', { statusCode: 500 });
77
+ expect(log.error).toHaveBeenCalledOnce();
78
+ });
79
+
80
+ it('prefers statusCode when both fields disagree', () => {
81
+ /* `statusCode` is the field APIError declares; `status` is the fallback
82
+ for a foreign shape, so it must not override the real one. */
83
+ const log = sink();
84
+ logAuthFailure(log, 'x', { statusCode: 401, status: 500 });
85
+ expect(log.warn).toHaveBeenCalledOnce();
86
+ expect(log.error).not.toHaveBeenCalled();
87
+ });
88
+ });
89
+ });
@@ -35,6 +35,7 @@ import { useCfgRouter, useLocalStorage, useQueryParams, useSession } from '../ho
35
35
  import { clearProfileCache } from '../hooks/useProfileCache';
36
36
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
37
37
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
38
+ import { formatAuthError, logAuthFailure } from '../utils/errors';
38
39
  import { isDev, isBrowser } from '../utils/env';
39
40
  import { authLogger } from '../utils/logger';
40
41
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
@@ -71,14 +72,6 @@ const EMAIL_STORAGE_KEY = 'auth_email';
71
72
 
72
73
  const AuthContext = createContext<AuthContextType | undefined>(undefined);
73
74
 
74
- /** Extract a user-facing message from an APIError response body. */
75
- const apiErrorMessage = (error: APIError): string => {
76
- const body = error.response as
77
- | { error?: string; detail?: string; message?: string }
78
- | undefined;
79
- return body?.error || body?.detail || body?.message || error.errorMessage;
80
- };
81
-
82
75
  const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
83
76
  const accounts = useAccountsContext();
84
77
  const session = useSession();
@@ -266,7 +259,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
266
259
  webmail: result.webmail ?? null,
267
260
  };
268
261
  } catch (error) {
269
- authLogger.error('Request OTP error:', error);
262
+ logAuthFailure(authLogger, 'Request OTP failed:', error);
270
263
 
271
264
  if (error instanceof APIError) {
272
265
  const body = error.response as
@@ -276,11 +269,11 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
276
269
  return {
277
270
  success: false,
278
271
  statusCode: error.statusCode,
279
- message: apiErrorMessage(error),
272
+ message: formatAuthError(error),
280
273
  retryAfter: typeof retryAfter === 'number' ? retryAfter : undefined,
281
274
  };
282
275
  }
283
- return { success: false, message: 'Failed to send OTP' };
276
+ return { success: false, message: formatAuthError(error, 'Failed to send OTP') };
284
277
  }
285
278
  },
286
279
  [],
@@ -359,15 +352,15 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
359
352
  should_prompt_2fa: result.should_prompt_2fa,
360
353
  };
361
354
  } catch (error) {
362
- authLogger.error('Verify OTP error:', error);
355
+ logAuthFailure(authLogger, 'Verify OTP failed:', error);
363
356
  Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
364
357
  category: AnalyticsCategory.AUTH,
365
358
  label: 'email',
366
359
  });
367
360
  if (error instanceof APIError) {
368
- return { success: false, message: apiErrorMessage(error) };
361
+ return { success: false, message: formatAuthError(error) };
369
362
  }
370
- return { success: false, message: 'Failed to verify OTP' };
363
+ return { success: false, message: formatAuthError(error, 'Failed to verify OTP') };
371
364
  }
372
365
  },
373
366
  [setStoredEmail, redirectManager, router],
@@ -440,7 +433,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
440
433
  should_prompt_2fa: result.should_prompt_2fa,
441
434
  };
442
435
  } catch (error) {
443
- authLogger.error('Password login error:', error);
436
+ logAuthFailure(authLogger, 'Password login failed:', error);
444
437
  Analytics.event(AnalyticsEvent.AUTH_PASSWORD_LOGIN_FAIL, {
445
438
  category: AnalyticsCategory.AUTH,
446
439
  label: 'password',
@@ -454,12 +447,12 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
454
447
  return {
455
448
  success: false,
456
449
  statusCode: error.statusCode,
457
- message: apiErrorMessage(error),
450
+ message: formatAuthError(error),
458
451
  errorCode: body?.error_code,
459
452
  retryAfter: typeof body?.retry_after === 'number' ? body.retry_after : undefined,
460
453
  };
461
454
  }
462
- return { success: false, message: 'Failed to sign in' };
455
+ return { success: false, message: formatAuthError(error, 'Failed to sign in') };
463
456
  }
464
457
  },
465
458
  [setStoredEmail, redirectManager, router],
@@ -9,34 +9,19 @@ import { APIError } from '../../_api/generated/helpers';
9
9
  * and 6-digit validation slightly differently — three copies that drifted. This
10
10
  * module is the single source for both, so every 2FA verb reports errors and
11
11
  * validates codes identically.
12
+ *
13
+ * Error extraction itself now lives one level up, in `utils/errors`: this
14
+ * module's own copy was the second of three spellings of the same chain across
15
+ * the auth layer, and the one that could not tell a status code from prose.
12
16
  */
13
17
 
14
18
  const TOTP_CODE_LENGTH = 6;
15
19
 
16
- /** A 2FA API error body may carry any of these message-ish fields. */
20
+ /** The one field the shared resolver does not read for us. */
17
21
  interface TwoFactorErrorBody {
18
- error?: unknown;
19
- detail?: unknown;
20
- message?: unknown;
21
22
  attempts_remaining?: unknown;
22
23
  }
23
24
 
24
- /**
25
- * Extract a human-readable message from a thrown error, unwrapping the
26
- * APIError response body (error → detail → message) before falling back.
27
- */
28
- export function extractTwoFactorError(err: unknown, fallback: string): string {
29
- if (err instanceof APIError) {
30
- const body = (err.response ?? null) as TwoFactorErrorBody | null;
31
- if (typeof body?.error === 'string') return body.error;
32
- if (typeof body?.detail === 'string') return body.detail;
33
- if (typeof body?.message === 'string') return body.message;
34
- return err.errorMessage ?? err.message;
35
- }
36
- if (err instanceof Error) return err.message;
37
- return fallback;
38
- }
39
-
40
25
  /** Pull `attempts_remaining` off an APIError body, or null if absent. */
41
26
  export function extractAttemptsRemaining(err: unknown): number | null {
42
27
  if (err instanceof APIError) {
@@ -6,6 +6,7 @@ import { auth } from '../../_api/generated/helpers/auth';
6
6
  import { CfgAccountsOauth } from '../../_api/generated/sdk.gen';
7
7
  import type { OAuthTokenResponse } from '../../_api/generated/types.gen';
8
8
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
9
+ import { formatAuthError } from '../utils/errors';
9
10
  import { authLogger } from '../utils/logger';
10
11
  import { useCfgRouter } from './useCfgRouter';
11
12
 
@@ -114,7 +115,7 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
114
115
  window.location.href = response.authorization_url;
115
116
 
116
117
  } catch (err) {
117
- const errorMessage = err instanceof Error ? err.message : 'Failed to start GitHub authentication';
118
+ const errorMessage = formatAuthError(err, 'Failed to start GitHub authentication');
118
119
  authLogger.error('GitHub OAuth start error:', err);
119
120
  setError(errorMessage);
120
121
  onError?.(errorMessage);
@@ -219,7 +220,7 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
219
220
  return;
220
221
 
221
222
  } catch (err) {
222
- const errorMessage = err instanceof Error ? err.message : 'GitHub authentication failed';
223
+ const errorMessage = formatAuthError(err, 'GitHub authentication failed');
223
224
  authLogger.error('GitHub OAuth callback error:', err);
224
225
  setError(errorMessage);
225
226
  onError?.(errorMessage);
@@ -4,7 +4,8 @@ import { useCallback, useState } from 'react';
4
4
 
5
5
  import { CfgTotpSetup } from '../../_api/generated/sdk.gen';
6
6
  import { authLogger } from '../utils/logger';
7
- import { extractTwoFactorError, isValidTotpCode } from './twoFactor.shared';
7
+ import { formatAuthError, logAuthFailure } from '../utils/errors';
8
+ import { isValidTotpCode } from './twoFactor.shared';
8
9
 
9
10
  export interface TwoFactorSetupData {
10
11
  /** Device ID to use for confirmation */
@@ -105,8 +106,8 @@ export const useTwoFactorSetup = (
105
106
 
106
107
  return data;
107
108
  } catch (err) {
108
- const errorMessage = extractTwoFactorError(err, 'Failed to start 2FA setup');
109
- authLogger.error('2FA setup error:', err);
109
+ const errorMessage = formatAuthError(err, 'Failed to start 2FA setup');
110
+ logAuthFailure(authLogger, '2FA setup failed:', err);
110
111
  setError(errorMessage);
111
112
  setSetupStep('idle');
112
113
  onError?.(errorMessage);
@@ -153,8 +154,8 @@ export const useTwoFactorSetup = (
153
154
  onComplete?.(codes);
154
155
  return codes;
155
156
  } catch (err) {
156
- const errorMessage = extractTwoFactorError(err, 'Invalid code. Please try again.');
157
- authLogger.error('2FA setup confirmation error:', err);
157
+ const errorMessage = formatAuthError(err, 'Invalid code. Please try again.');
158
+ logAuthFailure(authLogger, '2FA setup confirmation failed:', err);
158
159
  setError(errorMessage);
159
160
  setSetupStep('scanning'); // Go back to scanning step
160
161
  onError?.(errorMessage);
@@ -4,7 +4,8 @@ import { useCallback, useState } from 'react';
4
4
 
5
5
  import { CfgTotp } from '../../_api/generated/sdk.gen';
6
6
  import { authLogger } from '../utils/logger';
7
- import { extractTwoFactorError, isValidTotpCode } from './twoFactor.shared';
7
+ import { formatAuthError, logAuthFailure } from '../utils/errors';
8
+ import { isValidTotpCode } from './twoFactor.shared';
8
9
 
9
10
  export interface TwoFactorDevice {
10
11
  id: string;
@@ -70,8 +71,8 @@ export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
70
71
 
71
72
  authLogger.info('2FA status:', response.has_2fa_enabled ? 'enabled' : 'disabled');
72
73
  } catch (err) {
73
- const errorMessage = extractTwoFactorError(err, 'Failed to fetch 2FA status');
74
- authLogger.error('Failed to fetch 2FA status:', err);
74
+ const errorMessage = formatAuthError(err, 'Failed to fetch 2FA status');
75
+ logAuthFailure(authLogger, 'Failed to fetch 2FA status:', err);
75
76
  setError(errorMessage);
76
77
  } finally {
77
78
  setIsLoading(false);
@@ -98,8 +99,8 @@ export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
98
99
  authLogger.info('2FA disabled successfully');
99
100
  return true;
100
101
  } catch (err: unknown) {
101
- const errorMessage = extractTwoFactorError(err, 'Invalid verification code');
102
- authLogger.error('Failed to disable 2FA:', err);
102
+ const errorMessage = formatAuthError(err, 'Invalid verification code');
103
+ logAuthFailure(authLogger, 'Failed to disable 2FA:', err);
103
104
  setError(errorMessage);
104
105
  return false;
105
106
  } finally {
@@ -7,9 +7,10 @@ import { CfgTotpVerify } from '../../_api/generated/sdk.gen';
7
7
  import type { TotpVerifyUser, VerifyResponse } from '../../_api/generated/types.gen';
8
8
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
9
9
  import { authLogger } from '../utils/logger';
10
+ import { formatAuthError, logAuthFailure } from '../utils/errors';
10
11
  import { consumeSavedRedirect } from './useAuthRedirect';
11
12
  import { useCfgRouter } from './useCfgRouter';
12
- import { extractAttemptsRemaining, extractTwoFactorError, isValidTotpCode } from './twoFactor.shared';
13
+ import { extractAttemptsRemaining, isValidTotpCode } from './twoFactor.shared';
13
14
 
14
15
  export interface UseTwoFactorVerifyOptions {
15
16
  /** Callback on successful 2FA verification */
@@ -130,8 +131,8 @@ export const useTwoFactorVerify = (
130
131
  handleSuccess(response);
131
132
  return true;
132
133
  } catch (err) {
133
- authLogger.error('2FA TOTP verification error:', err);
134
- const errorMessage = extractTwoFactorError(err, 'Invalid verification code');
134
+ logAuthFailure(authLogger, '2FA TOTP verification failed:', err);
135
+ const errorMessage = formatAuthError(err, 'Invalid verification code');
135
136
  const attempts = extractAttemptsRemaining(err);
136
137
  if (attempts !== null) setAttemptsRemaining(attempts);
137
138
  setError(errorMessage);
@@ -183,8 +184,8 @@ export const useTwoFactorVerify = (
183
184
  handleSuccess(response);
184
185
  return true;
185
186
  } catch (err) {
186
- authLogger.error('2FA backup code verification error:', err);
187
- const errorMessage = extractTwoFactorError(err, 'Invalid backup code');
187
+ logAuthFailure(authLogger, '2FA backup code verification failed:', err);
188
+ const errorMessage = formatAuthError(err, 'Invalid backup code');
188
189
  const attempts = extractAttemptsRemaining(err);
189
190
  if (attempts !== null) setAttemptsRemaining(attempts);
190
191
  setError(errorMessage);
@@ -5,29 +5,163 @@ const prop = (value: unknown, key: string): unknown =>
5
5
  : undefined;
6
6
 
7
7
  /**
8
- * Format authentication error for display.
8
+ * The transport's own fallback message, e.g. `HTTP 404: ` or `HTTP 500: Boom`.
9
+ *
10
+ * `APIError` builds this when the caller supplies no message of its own
11
+ * (`_api/generated/helpers/errors.ts`), so it is never server prose — it is the
12
+ * status line restated. Under HTTP/2 `statusText` is empty, which is how a bare
13
+ * `HTTP 404:` reached a sign-in form and told the user nothing at all.
14
+ */
15
+ const TRANSPORT_FALLBACK = /^HTTP \d{3}:\s*/;
16
+
17
+ /**
18
+ * Translation keys for the copy this module can choose.
19
+ *
20
+ * Every one already exists, translated, in all 17 locales of `@djangocfg/i18n`
21
+ * — `api.errors.*` for transport conditions and `ui.errors.*` for the ones a
22
+ * person can act on. Nothing is added here: a second key saying "Server is
23
+ * unavailable" in a namespace of its own would be a second owner of the same
24
+ * sentence, and the one that loses is whichever the caller did not use.
25
+ *
26
+ * A 404 maps to `serverUnavailable`, not to `ui.errors.notFound` ("Not found"):
27
+ * on a sign-in POST the missing thing is the ENDPOINT, which tells the visitor
28
+ * nothing about a page and everything about the service being down.
29
+ */
30
+ export const AUTH_ERROR_KEYS = {
31
+ invalidCredentials: 'ui.errors.unauthorized',
32
+ rateLimited: 'api.errors.rateLimited',
33
+ serverUnavailable: 'api.errors.serverUnavailable',
34
+ networkError: 'api.errors.networkError',
35
+ generic: 'ui.errors.generic',
36
+ } as const;
37
+
38
+ export type AuthErrorKey = (typeof AUTH_ERROR_KEYS)[keyof typeof AUTH_ERROR_KEYS];
39
+
40
+ /**
41
+ * English copy for each key, so a caller without a translator still renders a
42
+ * sentence. The catalogue is authoritative; this is the offline twin.
43
+ */
44
+ const FALLBACK_COPY: Record<AuthErrorKey, string> = {
45
+ [AUTH_ERROR_KEYS.invalidCredentials]: 'Please sign in to continue',
46
+ [AUTH_ERROR_KEYS.rateLimited]: 'Too many requests. Please wait.',
47
+ [AUTH_ERROR_KEYS.serverUnavailable]: 'Server is unavailable',
48
+ [AUTH_ERROR_KEYS.networkError]: 'Network error',
49
+ [AUTH_ERROR_KEYS.generic]: 'Something went wrong',
50
+ };
51
+
52
+ /** Map a status code to the key whose copy explains it. */
53
+ const keyForStatus = (status: number | undefined): AuthErrorKey => {
54
+ if (status === undefined) return AUTH_ERROR_KEYS.generic;
55
+ if (status === 401 || status === 403) return AUTH_ERROR_KEYS.invalidCredentials;
56
+ // A 404 here is a missing endpoint, not a missing page — see AUTH_ERROR_KEYS.
57
+ if (status === 404) return AUTH_ERROR_KEYS.serverUnavailable;
58
+ if (status === 429) return AUTH_ERROR_KEYS.rateLimited;
59
+ if (status >= 500) return AUTH_ERROR_KEYS.serverUnavailable;
60
+ return AUTH_ERROR_KEYS.generic;
61
+ };
62
+
63
+ /** What the auth layer decided to show, and why. */
64
+ export interface AuthErrorCopy {
65
+ /** Ready-to-render text: server prose, or this module's English fallback. */
66
+ message: string;
67
+ /**
68
+ * Set only when `message` came from `FALLBACK_COPY`. A caller holding a
69
+ * translator renders `t(key)` instead; server prose carries no key because
70
+ * the backend already chose the words and the locale.
71
+ */
72
+ key?: AuthErrorKey;
73
+ }
74
+
75
+ /**
76
+ * Decide what a failed auth call should say.
77
+ *
78
+ * Order: text the SERVER wrote, then copy chosen from the status code, then a
79
+ * generic sentence. Server prose wins because the backend knows the specific
80
+ * reason ("This code has expired") that a status code cannot express.
9
81
  *
10
82
  * Each candidate is checked to be a string before it is returned. Under the
11
83
  * previous `any` the check was only that the property was truthy, so a DRF
12
84
  * field-error object (`{detail: {email: [...]}}`) reached the UI as
13
85
  * "[object Object]" — an error message that tells the user nothing.
86
+ *
87
+ * `HTTP <code>:` strings are rejected on the way through. They are the
88
+ * transport restating its own status line, not prose anyone wrote to be read,
89
+ * and treating them as a message is what put a bare `HTTP 404:` under a
90
+ * sign-in field. The code itself stays useful — `logAuthFailure` puts it in
91
+ * the console, where diagnosis belongs.
14
92
  */
15
- export const formatAuthError = (error: unknown): string => {
16
- if (typeof error === 'string') {
17
- return error;
93
+ export const resolveAuthError = (error: unknown, fallback?: string): AuthErrorCopy => {
94
+ if (typeof error === 'string' && error && !TRANSPORT_FALLBACK.test(error)) {
95
+ return { message: error };
18
96
  }
19
97
 
20
98
  const candidates = [
21
99
  prop(error, 'message'),
22
100
  prop(error, 'detail'),
101
+ prop(prop(error, 'response'), 'error'),
102
+ prop(prop(error, 'response'), 'detail'),
103
+ prop(prop(error, 'response'), 'message'),
23
104
  prop(prop(prop(error, 'response'), 'data'), 'detail'),
24
105
  ];
25
106
 
26
107
  for (const candidate of candidates) {
27
- if (typeof candidate === 'string' && candidate) {
28
- return candidate;
108
+ if (typeof candidate === 'string' && candidate && !TRANSPORT_FALLBACK.test(candidate)) {
109
+ return { message: candidate };
29
110
  }
30
111
  }
31
112
 
32
- return 'An unexpected error occurred';
113
+ // Both spellings are read: `statusCode` is what `APIError` declares, while a
114
+ // Response-shaped rejection reaching here from outside the interceptor
115
+ // carries `status`.
116
+ const rawStatus = prop(error, 'statusCode') ?? prop(error, 'status');
117
+ const status = typeof rawStatus === 'number' ? rawStatus : undefined;
118
+
119
+ // A caller's own fallback only applies where the status says nothing; a 404
120
+ // must not be reported as "Failed to send the code".
121
+ if (status === undefined && fallback) {
122
+ return { message: fallback };
123
+ }
124
+
125
+ const key = keyForStatus(status);
126
+ return { message: FALLBACK_COPY[key], key };
127
+ };
128
+
129
+ /**
130
+ * `resolveAuthError` for callers that only want the string.
131
+ *
132
+ * Kept because most call sites store a plain `error` string in form state and
133
+ * have no translator to hand.
134
+ */
135
+ export const formatAuthError = (error: unknown, fallback?: string): string =>
136
+ resolveAuthError(error, fallback).message;
137
+
138
+ /**
139
+ * Log a failed auth call at the level its cause deserves.
140
+ *
141
+ * A 4xx on an auth endpoint is the USER getting it wrong — a mistyped OTP, an
142
+ * expired code, a bad password. It is the endpoint working. Logged through
143
+ * `console.error` it became a production alert: `@djangocfg/devtools` captures
144
+ * `console.error` as an `ERROR` event, so every typo raised "❌ Error" in
145
+ * Telegram, complete with consola's `%c` CSS in the message body. Real faults
146
+ * drown in that.
147
+ *
148
+ * 5xx, a network failure and a broken response contract stay at `error`:
149
+ * nobody can fix those by retyping, and they are what the alert is for.
150
+ *
151
+ * Both `statusCode` (what `APIError` declares) and `status` are read, because
152
+ * a Response-shaped rejection can reach here from code that does not go
153
+ * through the interceptor.
154
+ */
155
+ export const logAuthFailure = (
156
+ log: { warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void },
157
+ message: string,
158
+ error: unknown,
159
+ ): void => {
160
+ const code = prop(error, 'statusCode') ?? prop(error, 'status');
161
+ const isClientError = typeof code === 'number' && code >= 400 && code < 500;
162
+ if (isClientError) {
163
+ log.warn(message, error);
164
+ return;
165
+ }
166
+ log.error(message, error);
33
167
  };
@@ -1,5 +1,12 @@
1
1
  export { validateEmail } from './validation';
2
- export { formatAuthError } from './errors';
2
+ export {
3
+ formatAuthError,
4
+ logAuthFailure,
5
+ resolveAuthError,
6
+ AUTH_ERROR_KEYS,
7
+ type AuthErrorCopy,
8
+ type AuthErrorKey,
9
+ } from './errors';
3
10
  export {
4
11
  resolveGuardIsLoading,
5
12
  shouldRedirectToAuth,