@djangocfg/api 2.1.566 → 2.1.569
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 +98 -69
- 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 +98 -69
- package/dist/auth.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OAuthAuthorizeRequestRequest.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OAuthAuthorizeResponse.ts +1 -1
- package/src/_api/generated/_cfg_accounts/schemas/OAuthCallbackRequestRequest.ts +1 -1
- package/src/_api/generated/_cfg_accounts/schemas/OAuthConnection.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OTPRequestRequest.ts +1 -1
- package/src/_api/generated/_cfg_accounts/schemas/OTPVerifyRequest.ts +1 -1
- package/src/_api/generated/_cfg_accounts/schemas/User.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLink.ts +1 -1
- package/src/_api/generated/_cfg_totp/schemas/TotpVerifyUser.ts +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.569",
|
|
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.569",
|
|
89
89
|
"@testing-library/react": "^16.3.2",
|
|
90
90
|
"@types/node": "^25.9.5",
|
|
91
91
|
"@types/react": "19.2.15",
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
7
|
export const OAuthAuthorizeRequestRequestSchema = z.object({
|
|
8
|
-
redirect_uri: z.url().optional(),
|
|
9
|
-
source_url: z.url().optional(),
|
|
8
|
+
redirect_uri: z.url().or(z.literal("")).optional(),
|
|
9
|
+
source_url: z.url().or(z.literal("")).optional(),
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
export type OAuthAuthorizeRequestRequest = z.infer<typeof OAuthAuthorizeRequestRequestSchema>;
|
|
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|
|
7
7
|
export const OAuthCallbackRequestRequestSchema = z.object({
|
|
8
8
|
code: z.string().min(10).max(500),
|
|
9
9
|
state: z.string().min(20).max(100),
|
|
10
|
-
redirect_uri: z.url().optional(),
|
|
10
|
+
redirect_uri: z.url().or(z.literal("")).optional(),
|
|
11
11
|
remember_me: z.boolean().default(false).optional(),
|
|
12
12
|
});
|
|
13
13
|
|
|
@@ -10,8 +10,8 @@ export const OAuthConnectionSchema = z.object({
|
|
|
10
10
|
provider: OAuthProviderEnumSchema,
|
|
11
11
|
provider_display: z.string(),
|
|
12
12
|
provider_username: z.string(),
|
|
13
|
-
provider_email: z.email(),
|
|
14
|
-
provider_avatar_url: z.url(),
|
|
13
|
+
provider_email: z.email().or(z.literal("")),
|
|
14
|
+
provider_avatar_url: z.url().or(z.literal("")),
|
|
15
15
|
connected_at: z.string().datetime({ offset: true }),
|
|
16
16
|
last_login_at: z.string().datetime({ offset: true }),
|
|
17
17
|
});
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
|
|
7
7
|
export const OTPRequestRequestSchema = z.object({
|
|
8
8
|
identifier: z.string().min(1),
|
|
9
|
-
source_url: z.url().optional(),
|
|
9
|
+
source_url: z.url().or(z.literal("")).optional(),
|
|
10
10
|
marketing_consent: z.boolean().nullable().optional(),
|
|
11
11
|
consent_disclosure_version: z.string().max(64).optional(),
|
|
12
12
|
});
|
|
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|
|
7
7
|
export const OTPVerifyRequestSchema = z.object({
|
|
8
8
|
identifier: z.string().min(1),
|
|
9
9
|
otp: z.string().min(4).max(4),
|
|
10
|
-
source_url: z.url().optional(),
|
|
10
|
+
source_url: z.url().or(z.literal("")).optional(),
|
|
11
11
|
remember_me: z.boolean().default(false).optional(),
|
|
12
12
|
});
|
|
13
13
|
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
|
|
7
7
|
export const UserSchema = z.object({
|
|
8
8
|
id: z.number().int(),
|
|
9
|
-
email: z.email(),
|
|
9
|
+
email: z.email().or(z.literal("")),
|
|
10
10
|
first_name: z.string().max(50).nullable().optional(),
|
|
11
11
|
last_name: z.string().max(50).nullable().optional(),
|
|
12
12
|
full_name: z.string(),
|
|
@@ -17,7 +17,7 @@ export const UserSchema = z.object({
|
|
|
17
17
|
position: z.string().max(100).nullable().optional(),
|
|
18
18
|
language: z.string().max(10).nullable().optional(),
|
|
19
19
|
timezone: z.string().max(64).nullable().optional(),
|
|
20
|
-
avatar: z.url().nullable(),
|
|
20
|
+
avatar: z.url().or(z.literal("")).nullable(),
|
|
21
21
|
is_staff: z.boolean(),
|
|
22
22
|
is_superuser: z.boolean(),
|
|
23
23
|
date_joined: z.string().datetime({ offset: true }),
|
|
@@ -8,7 +8,7 @@ import { WebmailLinkProviderEnumSchema } from "./WebmailLinkProviderEnum";
|
|
|
8
8
|
export const WebmailLinkSchema = z.object({
|
|
9
9
|
provider: WebmailLinkProviderEnumSchema,
|
|
10
10
|
provider_name: z.string(),
|
|
11
|
-
url: z.url(),
|
|
11
|
+
url: z.url().or(z.literal("")),
|
|
12
12
|
is_search: z.boolean(),
|
|
13
13
|
});
|
|
14
14
|
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
|
|
7
7
|
export const TotpVerifyUserSchema = z.object({
|
|
8
8
|
id: z.number().int(),
|
|
9
|
-
email: z.email(),
|
|
9
|
+
email: z.email().or(z.literal("")),
|
|
10
10
|
first_name: z.string().max(50).nullable().optional(),
|
|
11
11
|
last_name: z.string().max(50).nullable().optional(),
|
|
12
12
|
full_name: z.string(),
|
|
@@ -17,7 +17,7 @@ export const TotpVerifyUserSchema = z.object({
|
|
|
17
17
|
position: z.string().max(100).nullable().optional(),
|
|
18
18
|
language: z.string().max(10).nullable().optional(),
|
|
19
19
|
timezone: z.string().max(64).nullable().optional(),
|
|
20
|
-
avatar: z.url().nullable(),
|
|
20
|
+
avatar: z.url().or(z.literal("")).nullable(),
|
|
21
21
|
is_staff: z.boolean(),
|
|
22
22
|
is_superuser: z.boolean(),
|
|
23
23
|
date_joined: z.string().datetime({ offset: true }),
|
|
@@ -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);
|