@djangocfg/api 2.1.545 → 2.1.547
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 +331 -73
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +90 -1
- package/dist/auth.d.ts +90 -1
- package/dist/auth.mjs +331 -73
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +18 -0
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +96 -0
- package/dist/clients.d.ts +96 -0
- package/dist/clients.mjs +18 -0
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +18 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +171 -3
- package/dist/index.d.ts +171 -3
- package/dist/index.mjs +18 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenCreate.ts +64 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +157 -0
- package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginError.ts +13 -0
- package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginRequest.ts +13 -0
- package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginResponse.ts +18 -0
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +3 -0
- package/src/_api/generated/openapi.json +157 -0
- package/src/_api/generated/sdk.gen.ts +20 -1
- package/src/_api/generated/types.gen.ts +149 -0
- package/src/auth/__tests__/passwordLogin.test.tsx +239 -0
- package/src/auth/context/AccountsContext.tsx +32 -7
- package/src/auth/context/AuthContext.tsx +104 -1
- package/src/auth/context/types.ts +10 -1
- package/src/auth/hooks/useAuthForm.ts +84 -0
- package/src/auth/hooks/useAuthFormState.ts +3 -0
- package/src/auth/types/form.ts +41 -1
- package/src/auth/types/index.ts +2 -0
- package/src/auth/utils/analytics.ts +1 -0
|
@@ -587,6 +587,81 @@ export type OtpVerifyResponse = {
|
|
|
587
587
|
persistent_session?: boolean;
|
|
588
588
|
};
|
|
589
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Typed error response for password login.
|
|
592
|
+
*
|
|
593
|
+
* error_code values:
|
|
594
|
+
* - invalid_credentials — wrong email/password, inactive or deleted account
|
|
595
|
+
* - rate_limited — IP rate limit hit
|
|
596
|
+
* - locked_out — too many failed attempts for this email
|
|
597
|
+
*/
|
|
598
|
+
export type PasswordLoginError = {
|
|
599
|
+
/**
|
|
600
|
+
* Human-readable error message
|
|
601
|
+
*/
|
|
602
|
+
error: string;
|
|
603
|
+
/**
|
|
604
|
+
* Machine-readable error code
|
|
605
|
+
*/
|
|
606
|
+
error_code: string;
|
|
607
|
+
/**
|
|
608
|
+
* Seconds until the caller may retry (throttled responses only)
|
|
609
|
+
*/
|
|
610
|
+
retry_after?: number | null;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Validate email+password against live accounts only.
|
|
615
|
+
*
|
|
616
|
+
* Subclasses SimpleJWT so the field/validation contract stays theirs, but
|
|
617
|
+
* replaces ``validate()`` — the token pair itself is minted by the view via
|
|
618
|
+
* ``mint_tokens_for_request`` so the DPoP binding and remembered-session
|
|
619
|
+
* lifetime match OTP, OAuth and TOTP exactly.
|
|
620
|
+
*/
|
|
621
|
+
export type PasswordLoginRequest = {
|
|
622
|
+
/**
|
|
623
|
+
* Keep this browser signed in for 30 days
|
|
624
|
+
*/
|
|
625
|
+
remember_me?: boolean;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Password-login response.
|
|
630
|
+
*
|
|
631
|
+
* Mirrors the OTP verify response so a client can treat every login flow
|
|
632
|
+
* identically. ``requires_2fa=True`` carries a session id instead of tokens.
|
|
633
|
+
*/
|
|
634
|
+
export type PasswordLoginResponse = {
|
|
635
|
+
/**
|
|
636
|
+
* Whether 2FA verification is required
|
|
637
|
+
*/
|
|
638
|
+
requires_2fa?: boolean;
|
|
639
|
+
/**
|
|
640
|
+
* 2FA session ID (if requires_2fa is True)
|
|
641
|
+
*/
|
|
642
|
+
session_id?: string | null;
|
|
643
|
+
/**
|
|
644
|
+
* JWT refresh token (if requires_2fa is False)
|
|
645
|
+
*/
|
|
646
|
+
refresh?: string | null;
|
|
647
|
+
/**
|
|
648
|
+
* JWT access token (if requires_2fa is False)
|
|
649
|
+
*/
|
|
650
|
+
access?: string | null;
|
|
651
|
+
/**
|
|
652
|
+
* User information (if requires_2fa is False)
|
|
653
|
+
*/
|
|
654
|
+
user?: User | null;
|
|
655
|
+
/**
|
|
656
|
+
* Whether user should be prompted to enable 2FA
|
|
657
|
+
*/
|
|
658
|
+
should_prompt_2fa?: boolean;
|
|
659
|
+
/**
|
|
660
|
+
* Whether this login should persist across browser restarts
|
|
661
|
+
*/
|
|
662
|
+
persistent_session?: boolean;
|
|
663
|
+
};
|
|
664
|
+
|
|
590
665
|
/**
|
|
591
666
|
* Serializer for updating user profile.
|
|
592
667
|
*/
|
|
@@ -987,6 +1062,60 @@ export type OtpVerifyResponseWritable = {
|
|
|
987
1062
|
persistent_session?: boolean;
|
|
988
1063
|
};
|
|
989
1064
|
|
|
1065
|
+
/**
|
|
1066
|
+
* Validate email+password against live accounts only.
|
|
1067
|
+
*
|
|
1068
|
+
* Subclasses SimpleJWT so the field/validation contract stays theirs, but
|
|
1069
|
+
* replaces ``validate()`` — the token pair itself is minted by the view via
|
|
1070
|
+
* ``mint_tokens_for_request`` so the DPoP binding and remembered-session
|
|
1071
|
+
* lifetime match OTP, OAuth and TOTP exactly.
|
|
1072
|
+
*/
|
|
1073
|
+
export type PasswordLoginRequestWritable = {
|
|
1074
|
+
/**
|
|
1075
|
+
* Keep this browser signed in for 30 days
|
|
1076
|
+
*/
|
|
1077
|
+
remember_me?: boolean;
|
|
1078
|
+
email: string;
|
|
1079
|
+
password: string;
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Password-login response.
|
|
1084
|
+
*
|
|
1085
|
+
* Mirrors the OTP verify response so a client can treat every login flow
|
|
1086
|
+
* identically. ``requires_2fa=True`` carries a session id instead of tokens.
|
|
1087
|
+
*/
|
|
1088
|
+
export type PasswordLoginResponseWritable = {
|
|
1089
|
+
/**
|
|
1090
|
+
* Whether 2FA verification is required
|
|
1091
|
+
*/
|
|
1092
|
+
requires_2fa?: boolean;
|
|
1093
|
+
/**
|
|
1094
|
+
* 2FA session ID (if requires_2fa is True)
|
|
1095
|
+
*/
|
|
1096
|
+
session_id?: string | null;
|
|
1097
|
+
/**
|
|
1098
|
+
* JWT refresh token (if requires_2fa is False)
|
|
1099
|
+
*/
|
|
1100
|
+
refresh?: string | null;
|
|
1101
|
+
/**
|
|
1102
|
+
* JWT access token (if requires_2fa is False)
|
|
1103
|
+
*/
|
|
1104
|
+
access?: string | null;
|
|
1105
|
+
/**
|
|
1106
|
+
* User information (if requires_2fa is False)
|
|
1107
|
+
*/
|
|
1108
|
+
user?: UserWritable | null;
|
|
1109
|
+
/**
|
|
1110
|
+
* Whether user should be prompted to enable 2FA
|
|
1111
|
+
*/
|
|
1112
|
+
should_prompt_2fa?: boolean;
|
|
1113
|
+
/**
|
|
1114
|
+
* Whether this login should persist across browser restarts
|
|
1115
|
+
*/
|
|
1116
|
+
persistent_session?: boolean;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
990
1119
|
export type TokenRefreshWritable = {
|
|
991
1120
|
refresh: string;
|
|
992
1121
|
};
|
|
@@ -1428,6 +1557,26 @@ export type CfgAccountsProfileUpdateUpdateResponses = {
|
|
|
1428
1557
|
|
|
1429
1558
|
export type CfgAccountsProfileUpdateUpdateResponse = CfgAccountsProfileUpdateUpdateResponses[keyof CfgAccountsProfileUpdateUpdateResponses];
|
|
1430
1559
|
|
|
1560
|
+
export type CfgAccountsTokenCreateData = {
|
|
1561
|
+
body: PasswordLoginRequestWritable;
|
|
1562
|
+
path?: never;
|
|
1563
|
+
query?: never;
|
|
1564
|
+
url: '/cfg/accounts/token/';
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
export type CfgAccountsTokenCreateErrors = {
|
|
1568
|
+
401: PasswordLoginError;
|
|
1569
|
+
429: PasswordLoginError;
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
export type CfgAccountsTokenCreateError = CfgAccountsTokenCreateErrors[keyof CfgAccountsTokenCreateErrors];
|
|
1573
|
+
|
|
1574
|
+
export type CfgAccountsTokenCreateResponses = {
|
|
1575
|
+
200: PasswordLoginResponse;
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
export type CfgAccountsTokenCreateResponse = CfgAccountsTokenCreateResponses[keyof CfgAccountsTokenCreateResponses];
|
|
1579
|
+
|
|
1431
1580
|
export type CfgAccountsTokenBlacklistCreateData = {
|
|
1432
1581
|
body: TokenBlacklistRequest;
|
|
1433
1582
|
path?: never;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password login contract test.
|
|
3
|
+
*
|
|
4
|
+
* The password grant returns the SAME envelope as OTP verify, and the point of
|
|
5
|
+
* that is a single session owner: tokens are written by exactly one path, and
|
|
6
|
+
* the 2FA branch carries a session id and NO tokens. These three cases are the
|
|
7
|
+
* regression guard for a second token store creeping in — a bespoke fetch that
|
|
8
|
+
* "also" saves an access token would pass a shape test and fail these.
|
|
9
|
+
*
|
|
10
|
+
* `AccountsContext` is mocked at the module boundary so the test drives the
|
|
11
|
+
* real `AuthContext.login` (branching, redirect suppression, error mapping)
|
|
12
|
+
* against a scripted server envelope.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// @vitest-environment jsdom
|
|
16
|
+
import { act, renderHook } from '@testing-library/react';
|
|
17
|
+
import React from 'react';
|
|
18
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import { APIError } from '../../_api/generated/helpers';
|
|
21
|
+
|
|
22
|
+
const hardPush = vi.fn();
|
|
23
|
+
const hardReplace = vi.fn();
|
|
24
|
+
|
|
25
|
+
vi.mock('next/navigation', () => ({
|
|
26
|
+
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), back: vi.fn() }),
|
|
27
|
+
usePathname: () => '/auth',
|
|
28
|
+
useSearchParams: () => new URLSearchParams(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../hooks/useCfgRouter', () => ({
|
|
32
|
+
useCfgRouter: () => ({ push: vi.fn(), replace: vi.fn(), hardPush, hardReplace }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// The token store stands in for the ONE write path. Asserting on this fake is
|
|
36
|
+
// how "a 401 stored nothing" and "the 2FA branch stored nothing" are proven.
|
|
37
|
+
const tokens: { access: string | null; refresh: string | null } = { access: null, refresh: null };
|
|
38
|
+
vi.mock('../../_api/generated/helpers/auth', () => ({
|
|
39
|
+
// The generated client self-installs this at import time; the mock must
|
|
40
|
+
// carry it or every module that touches the SDK fails to load.
|
|
41
|
+
installAuthOnClient: () => {},
|
|
42
|
+
auth: {
|
|
43
|
+
getToken: () => tokens.access,
|
|
44
|
+
getRefreshToken: () => tokens.refresh,
|
|
45
|
+
isAuthenticated: () => Boolean(tokens.access),
|
|
46
|
+
setSession: ({ access, refresh }: { access: string; refresh?: string | null }) => {
|
|
47
|
+
tokens.access = access;
|
|
48
|
+
tokens.refresh = refresh ?? null;
|
|
49
|
+
},
|
|
50
|
+
clearSession: () => {
|
|
51
|
+
tokens.access = null;
|
|
52
|
+
tokens.refresh = null;
|
|
53
|
+
},
|
|
54
|
+
onSessionExpired: () => () => {},
|
|
55
|
+
subscribe: () => () => {},
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
vi.mock('../utils/logger', () => ({
|
|
60
|
+
authLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
vi.mock('../hooks/useSession', () => ({
|
|
64
|
+
useSession: () => ({ status: tokens.access ? 'authenticated' : 'anonymous' }),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Mirrors AccountsContext.loginWithPassword: settles the envelope through the
|
|
68
|
+
// same store the OTP path uses, then hands the raw response back.
|
|
69
|
+
const loginWithPassword = vi.fn();
|
|
70
|
+
const accountsValue = {
|
|
71
|
+
profile: undefined,
|
|
72
|
+
isLoadingProfile: false,
|
|
73
|
+
profileError: null,
|
|
74
|
+
updateProfile: vi.fn(),
|
|
75
|
+
partialUpdateProfile: vi.fn(),
|
|
76
|
+
uploadAvatar: vi.fn(),
|
|
77
|
+
refreshProfile: vi.fn().mockResolvedValue(undefined),
|
|
78
|
+
requestOTP: vi.fn(),
|
|
79
|
+
verifyOTP: vi.fn(),
|
|
80
|
+
loginWithPassword,
|
|
81
|
+
logout: vi.fn(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
vi.mock('../context/AccountsContext', () => ({
|
|
85
|
+
AccountsProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
86
|
+
useAccountsContext: () => accountsValue,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const USER = { id: 7, email: 'jane@example.com' };
|
|
90
|
+
|
|
91
|
+
/** Script `loginWithPassword` with a server envelope, settled like the real one. */
|
|
92
|
+
function respondWith(envelope: Record<string, unknown>) {
|
|
93
|
+
loginWithPassword.mockImplementation(async () => {
|
|
94
|
+
const { auth } = await import('../../_api/generated/helpers/auth');
|
|
95
|
+
if (envelope.requires_2fa && envelope.session_id) return envelope;
|
|
96
|
+
if (envelope.access && envelope.refresh) {
|
|
97
|
+
auth.setSession({
|
|
98
|
+
access: envelope.access as string,
|
|
99
|
+
refresh: envelope.refresh as string,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return envelope;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function renderAuth() {
|
|
107
|
+
const { AuthProvider, useAuth } = await import('../context/AuthContext');
|
|
108
|
+
return renderHook(() => useAuth(), {
|
|
109
|
+
wrapper: ({ children }: { children: React.ReactNode }) => (
|
|
110
|
+
<AuthProvider>{children}</AuthProvider>
|
|
111
|
+
),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('AuthContext.login (password grant)', () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
vi.clearAllMocks();
|
|
118
|
+
tokens.access = null;
|
|
119
|
+
tokens.refresh = null;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('stores the token pair and returns the user on success', async () => {
|
|
123
|
+
respondWith({ requires_2fa: false, access: 'access-1', refresh: 'refresh-1', user: USER });
|
|
124
|
+
const { result } = await renderAuth();
|
|
125
|
+
|
|
126
|
+
let outcome!: Awaited<ReturnType<typeof result.current.login>>;
|
|
127
|
+
await act(async () => {
|
|
128
|
+
outcome = await result.current.login('jane@example.com', 'correct horse', {
|
|
129
|
+
skipRedirect: true,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(outcome.success).toBe(true);
|
|
134
|
+
expect(outcome.user).toEqual(USER);
|
|
135
|
+
expect(outcome.requires_2fa).toBeUndefined();
|
|
136
|
+
expect(tokens).toEqual({ access: 'access-1', refresh: 'refresh-1' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('sends email, password and remember_me to the password endpoint', async () => {
|
|
140
|
+
respondWith({ requires_2fa: false, access: 'access-1', refresh: 'refresh-1', user: USER });
|
|
141
|
+
const { result } = await renderAuth();
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
await result.current.login('jane@example.com', 'correct horse', {
|
|
145
|
+
skipRedirect: true,
|
|
146
|
+
rememberMe: true,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(loginWithPassword).toHaveBeenCalledWith({
|
|
151
|
+
email: 'jane@example.com',
|
|
152
|
+
password: 'correct horse',
|
|
153
|
+
remember_me: true,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('surfaces a 401 as a typed failure and stores nothing', async () => {
|
|
158
|
+
loginWithPassword.mockRejectedValue(
|
|
159
|
+
new APIError(
|
|
160
|
+
401,
|
|
161
|
+
'Unauthorized',
|
|
162
|
+
{ error: 'Authentication failed', error_code: 'invalid_credentials' },
|
|
163
|
+
'/cfg/accounts/token/',
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
const { result } = await renderAuth();
|
|
167
|
+
|
|
168
|
+
let outcome!: Awaited<ReturnType<typeof result.current.login>>;
|
|
169
|
+
await act(async () => {
|
|
170
|
+
outcome = await result.current.login('jane@example.com', 'wrong', { skipRedirect: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(outcome.success).toBe(false);
|
|
174
|
+
expect(outcome.statusCode).toBe(401);
|
|
175
|
+
expect(outcome.errorCode).toBe('invalid_credentials');
|
|
176
|
+
expect(outcome.message).toBe('Authentication failed');
|
|
177
|
+
expect(tokens).toEqual({ access: null, refresh: null });
|
|
178
|
+
expect(hardPush).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('carries retry_after from a 429 lockout', async () => {
|
|
182
|
+
loginWithPassword.mockRejectedValue(
|
|
183
|
+
new APIError(
|
|
184
|
+
429,
|
|
185
|
+
'Too Many Requests',
|
|
186
|
+
{
|
|
187
|
+
error: 'Too many failed attempts. Please try again later.',
|
|
188
|
+
error_code: 'locked_out',
|
|
189
|
+
retry_after: 900,
|
|
190
|
+
},
|
|
191
|
+
'/cfg/accounts/token/',
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
const { result } = await renderAuth();
|
|
195
|
+
|
|
196
|
+
let outcome!: Awaited<ReturnType<typeof result.current.login>>;
|
|
197
|
+
await act(async () => {
|
|
198
|
+
outcome = await result.current.login('jane@example.com', 'wrong', { skipRedirect: true });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(outcome.success).toBe(false);
|
|
202
|
+
expect(outcome.statusCode).toBe(429);
|
|
203
|
+
expect(outcome.errorCode).toBe('locked_out');
|
|
204
|
+
expect(outcome.retryAfter).toBe(900);
|
|
205
|
+
expect(tokens).toEqual({ access: null, refresh: null });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('returns the session id and stores NO tokens when 2FA is required', async () => {
|
|
209
|
+
respondWith({ requires_2fa: true, session_id: 'sess-abc', should_prompt_2fa: false });
|
|
210
|
+
const { result } = await renderAuth();
|
|
211
|
+
|
|
212
|
+
let outcome!: Awaited<ReturnType<typeof result.current.login>>;
|
|
213
|
+
await act(async () => {
|
|
214
|
+
outcome = await result.current.login('jane@example.com', 'correct horse', {
|
|
215
|
+
skipRedirect: true,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(outcome.success).toBe(true);
|
|
220
|
+
expect(outcome.requires_2fa).toBe(true);
|
|
221
|
+
expect(outcome.session_id).toBe('sess-abc');
|
|
222
|
+
expect(outcome.user).toBeUndefined();
|
|
223
|
+
expect(tokens).toEqual({ access: null, refresh: null });
|
|
224
|
+
expect(hardPush).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('redirects on success when the caller does not opt out', async () => {
|
|
228
|
+
respondWith({ requires_2fa: false, access: 'access-1', refresh: 'refresh-1', user: USER });
|
|
229
|
+
const { result } = await renderAuth();
|
|
230
|
+
|
|
231
|
+
await act(async () => {
|
|
232
|
+
await result.current.login('jane@example.com', 'correct horse', {
|
|
233
|
+
redirectUrl: '/dashboard',
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(hardPush).toHaveBeenCalledWith('/dashboard');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
useCfgAccountsProfileAvatarCreate,
|
|
26
26
|
useCfgAccountsProfilePartialUpdate,
|
|
27
27
|
useCfgAccountsProfileUpdateUpdate,
|
|
28
|
+
useCfgAccountsTokenCreate,
|
|
28
29
|
} from '../../_api/generated/_cfg_accounts/hooks';
|
|
29
30
|
import { PatchedCfgUserUpdateRequestSchema } from '../../_api/generated/_cfg_accounts/schemas';
|
|
30
31
|
import { clearProfileCache, getCachedProfile, setCachedProfile } from '../hooks/useProfileCache';
|
|
@@ -57,6 +58,8 @@ import type {
|
|
|
57
58
|
OtpVerifyRequest,
|
|
58
59
|
OtpRequestResponse,
|
|
59
60
|
OtpVerifyResponse,
|
|
61
|
+
PasswordLoginRequestWritable,
|
|
62
|
+
PasswordLoginResponse,
|
|
60
63
|
} from '../../_api/generated/_cfg_accounts/types.gen';
|
|
61
64
|
|
|
62
65
|
export { PatchedCfgUserUpdateRequestSchema };
|
|
@@ -78,6 +81,7 @@ export interface AccountsContextValue {
|
|
|
78
81
|
|
|
79
82
|
requestOTP: (data: OtpRequestRequest) => Promise<OtpRequestResponse>;
|
|
80
83
|
verifyOTP: (data: OtpVerifyRequest) => Promise<OtpVerifyResponse>;
|
|
84
|
+
loginWithPassword: (data: PasswordLoginRequestWritable) => Promise<PasswordLoginResponse>;
|
|
81
85
|
logout: () => void;
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -111,6 +115,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
111
115
|
const { trigger: triggerAvatar } = useCfgAccountsProfileAvatarCreate();
|
|
112
116
|
const { trigger: triggerOtpRequest } = useCfgAccountsOtpRequestCreate();
|
|
113
117
|
const { trigger: triggerOtpVerify } = useCfgAccountsOtpVerifyCreate();
|
|
118
|
+
const { trigger: triggerPasswordLogin } = useCfgAccountsTokenCreate();
|
|
114
119
|
|
|
115
120
|
const refreshProfile = useCallback(async (options?: { callerId?: string; force?: boolean }): Promise<User | undefined> => {
|
|
116
121
|
const { callerId, force } = options || {};
|
|
@@ -177,12 +182,16 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
177
182
|
return result as OtpRequestResponse;
|
|
178
183
|
}, [triggerOtpRequest]);
|
|
179
184
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
// OTP verify and password login return the SAME envelope by design, so both
|
|
186
|
+
// settle it here — one 2FA branch, one token write, one profile refresh. A
|
|
187
|
+
// second copy of this is a second session owner.
|
|
188
|
+
const settleLoginEnvelope = useCallback(async (
|
|
189
|
+
result: OtpVerifyResponse | PasswordLoginResponse,
|
|
190
|
+
callerId: string,
|
|
191
|
+
): Promise<void> => {
|
|
183
192
|
if (result.requires_2fa && result.session_id) {
|
|
184
193
|
authLogger.info('2FA required, session:', result.session_id);
|
|
185
|
-
return
|
|
194
|
+
return;
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
if (result.access && result.refresh) {
|
|
@@ -194,14 +203,28 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
194
203
|
// next page load and would never look at sessionStorage again.
|
|
195
204
|
auth.setSession({ access: result.access, refresh: result.refresh });
|
|
196
205
|
try {
|
|
197
|
-
await refreshProfile({ callerId
|
|
206
|
+
await refreshProfile({ callerId, force: true });
|
|
198
207
|
} catch (profileError) {
|
|
199
|
-
authLogger.warn(
|
|
208
|
+
authLogger.warn(`Profile refresh failed after ${callerId} (tokens are saved):`, profileError);
|
|
200
209
|
}
|
|
201
210
|
}
|
|
211
|
+
}, [refreshProfile]);
|
|
202
212
|
|
|
213
|
+
const verifyOTP = useCallback(async (data: OtpVerifyRequest): Promise<OtpVerifyResponse> => {
|
|
214
|
+
const result = (await triggerOtpVerify({ body: data })) as OtpVerifyResponse;
|
|
215
|
+
await settleLoginEnvelope(result, 'verifyOTP');
|
|
216
|
+
return result;
|
|
217
|
+
}, [triggerOtpVerify, settleLoginEnvelope]);
|
|
218
|
+
|
|
219
|
+
const loginWithPassword = useCallback(async (
|
|
220
|
+
data: PasswordLoginRequestWritable,
|
|
221
|
+
): Promise<PasswordLoginResponse> => {
|
|
222
|
+
// Errors propagate: the SWR trigger throws APIError for 401/429 and the
|
|
223
|
+
// caller maps them. Nothing is written to the session on a failure.
|
|
224
|
+
const result = (await triggerPasswordLogin({ body: data })) as PasswordLoginResponse;
|
|
225
|
+
await settleLoginEnvelope(result, 'loginWithPassword');
|
|
203
226
|
return result;
|
|
204
|
-
}, [
|
|
227
|
+
}, [triggerPasswordLogin, settleLoginEnvelope]);
|
|
205
228
|
|
|
206
229
|
const logout = useCallback(() => {
|
|
207
230
|
// Best-effort server-side revocation: without blacklisting, a stolen
|
|
@@ -229,6 +252,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
229
252
|
refreshProfile,
|
|
230
253
|
requestOTP,
|
|
231
254
|
verifyOTP,
|
|
255
|
+
loginWithPassword,
|
|
232
256
|
logout,
|
|
233
257
|
}), [
|
|
234
258
|
profile,
|
|
@@ -240,6 +264,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
240
264
|
refreshProfile,
|
|
241
265
|
requestOTP,
|
|
242
266
|
verifyOTP,
|
|
267
|
+
loginWithPassword,
|
|
243
268
|
logout,
|
|
244
269
|
]);
|
|
245
270
|
|
|
@@ -40,7 +40,12 @@ import { authLogger } from '../utils/logger';
|
|
|
40
40
|
import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
41
41
|
|
|
42
42
|
import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
43
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
OTPRequestConsent,
|
|
45
|
+
OTPRequestResult,
|
|
46
|
+
PasswordLoginOptions,
|
|
47
|
+
PasswordLoginResult,
|
|
48
|
+
} from '../types';
|
|
44
49
|
import type { OtpRequestRequest } from '../../_api/generated/_cfg_accounts/types.gen';
|
|
45
50
|
|
|
46
51
|
// The ONLY place route conventions are declared. '/auth' is the sign-in page
|
|
@@ -368,6 +373,98 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
368
373
|
[setStoredEmail, redirectManager, router],
|
|
369
374
|
);
|
|
370
375
|
|
|
376
|
+
// ── Password login flow ────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
// Same envelope, same branches and the same single session owner as
|
|
379
|
+
// verifyOTP — only the credential differs. Anything that diverges here
|
|
380
|
+
// (a second token store, a bespoke fetch) forks the session.
|
|
381
|
+
const login = useCallback(
|
|
382
|
+
async (
|
|
383
|
+
email: string,
|
|
384
|
+
password: string,
|
|
385
|
+
options?: PasswordLoginOptions,
|
|
386
|
+
): Promise<PasswordLoginResult> => {
|
|
387
|
+
const { redirectUrl, skipRedirect, rememberMe = false } = options || {};
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
// The caller opting out of redirect commits to handling ALL navigation
|
|
391
|
+
// for this login — including the 2FA continuation and success screen.
|
|
392
|
+
pendingManualNavRef.current = Boolean(skipRedirect);
|
|
393
|
+
|
|
394
|
+
const result = await accountsRef.current.loginWithPassword({
|
|
395
|
+
email,
|
|
396
|
+
password,
|
|
397
|
+
remember_me: rememberMe,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (result.requires_2fa && result.session_id) {
|
|
401
|
+
authLogger.info('2FA required, session:', result.session_id);
|
|
402
|
+
return {
|
|
403
|
+
success: true,
|
|
404
|
+
message: 'Password verified, 2FA required',
|
|
405
|
+
requires_2fa: true,
|
|
406
|
+
session_id: result.session_id,
|
|
407
|
+
should_prompt_2fa: result.should_prompt_2fa,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!result.access || !result.refresh) {
|
|
412
|
+
authLogger.error('Password login returned invalid response:', result);
|
|
413
|
+
return { success: false, message: 'Invalid login response' };
|
|
414
|
+
}
|
|
415
|
+
setStoredEmail(email);
|
|
416
|
+
|
|
417
|
+
Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
|
|
418
|
+
category: AnalyticsCategory.AUTH,
|
|
419
|
+
label: 'password',
|
|
420
|
+
});
|
|
421
|
+
if (result.user?.id) {
|
|
422
|
+
Analytics.setUser(String(result.user.id));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!skipRedirect) {
|
|
426
|
+
// Priority: saved back-url (per-visit intent from the guard) >
|
|
427
|
+
// explicit redirectUrl (static app default) > config default.
|
|
428
|
+
// hardPush = full page load so every context reinitializes cleanly.
|
|
429
|
+
const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
|
|
430
|
+
if (saved) redirectManager.clearRedirect();
|
|
431
|
+
const finalRedirectUrl = saved || redirectUrl || routesRef.current.defaultCallback;
|
|
432
|
+
authLogger.info('Redirecting after auth to:', finalRedirectUrl);
|
|
433
|
+
router.hardPush(finalRedirectUrl);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
success: true,
|
|
438
|
+
message: 'Login successful',
|
|
439
|
+
user: result.user as UserProfile,
|
|
440
|
+
should_prompt_2fa: result.should_prompt_2fa,
|
|
441
|
+
};
|
|
442
|
+
} catch (error) {
|
|
443
|
+
authLogger.error('Password login error:', error);
|
|
444
|
+
Analytics.event(AnalyticsEvent.AUTH_PASSWORD_LOGIN_FAIL, {
|
|
445
|
+
category: AnalyticsCategory.AUTH,
|
|
446
|
+
label: 'password',
|
|
447
|
+
});
|
|
448
|
+
if (error instanceof APIError) {
|
|
449
|
+
// 429 carries `retry_after`; 401 carries only `error_code`. Both are
|
|
450
|
+
// surfaced so a form can show a countdown instead of a dead message.
|
|
451
|
+
const body = error.response as
|
|
452
|
+
| { error_code?: string; retry_after?: number }
|
|
453
|
+
| undefined;
|
|
454
|
+
return {
|
|
455
|
+
success: false,
|
|
456
|
+
statusCode: error.statusCode,
|
|
457
|
+
message: apiErrorMessage(error),
|
|
458
|
+
errorCode: body?.error_code,
|
|
459
|
+
retryAfter: typeof body?.retry_after === 'number' ? body.retry_after : undefined,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return { success: false, message: 'Failed to sign in' };
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
[setStoredEmail, redirectManager, router],
|
|
466
|
+
);
|
|
467
|
+
|
|
371
468
|
// ── Session actions ────────────────────────────────────────────────────────
|
|
372
469
|
|
|
373
470
|
const refreshToken = useCallback(async (): Promise<{ success: boolean; message: string }> => {
|
|
@@ -442,6 +539,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
442
539
|
clearSavedEmail: clearStoredEmail,
|
|
443
540
|
requestOTP,
|
|
444
541
|
verifyOTP,
|
|
542
|
+
login,
|
|
445
543
|
refreshToken,
|
|
446
544
|
logout,
|
|
447
545
|
saveRedirectUrl: redirectManager.setRedirect,
|
|
@@ -464,6 +562,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
464
562
|
clearStoredEmail,
|
|
465
563
|
requestOTP,
|
|
466
564
|
verifyOTP,
|
|
565
|
+
login,
|
|
467
566
|
refreshToken,
|
|
468
567
|
logout,
|
|
469
568
|
redirectManager,
|
|
@@ -522,6 +621,10 @@ const defaultAuthState: AuthContextType = {
|
|
|
522
621
|
authLogger.warn('useAuth: verifyOTP called outside AuthProvider');
|
|
523
622
|
return { success: false, message: 'AuthProvider not available' };
|
|
524
623
|
},
|
|
624
|
+
login: async () => {
|
|
625
|
+
authLogger.warn('useAuth: login called outside AuthProvider');
|
|
626
|
+
return { success: false, message: 'AuthProvider not available' };
|
|
627
|
+
},
|
|
525
628
|
refreshToken: async () => {
|
|
526
629
|
authLogger.warn('useAuth: refreshToken called outside AuthProvider');
|
|
527
630
|
return { success: false, message: 'AuthProvider not available' };
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
3
|
import type { User } from '../../';
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
OTPRequestConsent,
|
|
6
|
+
OTPRequestResult,
|
|
7
|
+
PasswordLoginOptions,
|
|
8
|
+
PasswordLoginResult,
|
|
9
|
+
} from '../types';
|
|
5
10
|
|
|
6
11
|
// User profile type
|
|
7
12
|
export type UserProfile = User;
|
|
@@ -58,6 +63,10 @@ export interface AuthContextType {
|
|
|
58
63
|
session_id?: string | null;
|
|
59
64
|
should_prompt_2fa?: boolean;
|
|
60
65
|
}>;
|
|
66
|
+
// Password Method - same envelope and the same session owner as verifyOTP;
|
|
67
|
+
// `requires_2fa` hands off to the existing 2FA flow with a session id.
|
|
68
|
+
login: (email: string, password: string, options?: PasswordLoginOptions) => Promise<PasswordLoginResult>;
|
|
69
|
+
|
|
61
70
|
refreshToken: () => Promise<{ success: boolean; message: string }>;
|
|
62
71
|
logout: () => void;
|
|
63
72
|
|