@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/dist/auth.cjs +87 -58
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +73 -3
- package/dist/auth.d.ts +73 -3
- package/dist/auth.mjs +87 -58
- package/dist/auth.mjs.map +1 -1
- package/package.json +2 -2
- package/src/auth/__tests__/formatAuthError.test.ts +70 -6
- package/src/auth/__tests__/logAuthFailure.test.ts +89 -0
- package/src/auth/context/AuthContext.tsx +10 -17
- package/src/auth/hooks/twoFactor.shared.ts +5 -20
- package/src/auth/hooks/useGithubAuth.ts +3 -2
- package/src/auth/hooks/useTwoFactorSetup.ts +6 -5
- package/src/auth/hooks/useTwoFactorStatus.ts +6 -5
- package/src/auth/hooks/useTwoFactorVerify.ts +6 -5
- package/src/auth/utils/errors.ts +141 -7
- package/src/auth/utils/index.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
6
|
-
* fallback. The precedence order matters:
|
|
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 = '
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
109
|
-
authLogger
|
|
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 =
|
|
157
|
-
authLogger
|
|
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 {
|
|
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 =
|
|
74
|
-
authLogger
|
|
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 =
|
|
102
|
-
authLogger
|
|
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,
|
|
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
|
|
134
|
-
const errorMessage =
|
|
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
|
|
187
|
-
const errorMessage =
|
|
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);
|
package/src/auth/utils/errors.ts
CHANGED
|
@@ -5,29 +5,163 @@ const prop = (value: unknown, key: string): unknown =>
|
|
|
5
5
|
: undefined;
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/src/auth/utils/index.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export { validateEmail } from './validation';
|
|
2
|
-
export {
|
|
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,
|