@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.
Files changed (40) hide show
  1. package/dist/auth.cjs +331 -73
  2. package/dist/auth.cjs.map +1 -1
  3. package/dist/auth.d.cts +90 -1
  4. package/dist/auth.d.ts +90 -1
  5. package/dist/auth.mjs +331 -73
  6. package/dist/auth.mjs.map +1 -1
  7. package/dist/clients.cjs +18 -0
  8. package/dist/clients.cjs.map +1 -1
  9. package/dist/clients.d.cts +96 -0
  10. package/dist/clients.d.ts +96 -0
  11. package/dist/clients.mjs +18 -0
  12. package/dist/clients.mjs.map +1 -1
  13. package/dist/hooks.cjs.map +1 -1
  14. package/dist/hooks.mjs.map +1 -1
  15. package/dist/index.cjs +18 -0
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +171 -3
  18. package/dist/index.d.ts +171 -3
  19. package/dist/index.mjs +18 -0
  20. package/dist/index.mjs.map +1 -1
  21. package/package.json +2 -2
  22. package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
  23. package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenCreate.ts +64 -0
  24. package/src/_api/generated/_cfg_accounts/openapi.json +157 -0
  25. package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginError.ts +13 -0
  26. package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginRequest.ts +13 -0
  27. package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginResponse.ts +18 -0
  28. package/src/_api/generated/_cfg_accounts/schemas/index.ts +3 -0
  29. package/src/_api/generated/openapi.json +157 -0
  30. package/src/_api/generated/sdk.gen.ts +20 -1
  31. package/src/_api/generated/types.gen.ts +149 -0
  32. package/src/auth/__tests__/passwordLogin.test.tsx +239 -0
  33. package/src/auth/context/AccountsContext.tsx +32 -7
  34. package/src/auth/context/AuthContext.tsx +104 -1
  35. package/src/auth/context/types.ts +10 -1
  36. package/src/auth/hooks/useAuthForm.ts +84 -0
  37. package/src/auth/hooks/useAuthFormState.ts +3 -0
  38. package/src/auth/types/form.ts +41 -1
  39. package/src/auth/types/index.ts +2 -0
  40. 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
- const verifyOTP = useCallback(async (data: OtpVerifyRequest): Promise<OtpVerifyResponse> => {
181
- const result = (await triggerOtpVerify({ body: data })) as OtpVerifyResponse;
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 result;
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: 'verifyOTP', force: true });
206
+ await refreshProfile({ callerId, force: true });
198
207
  } catch (profileError) {
199
- authLogger.warn('Profile refresh failed after OTP verify (tokens are saved):', profileError);
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
- }, [triggerOtpVerify, refreshProfile]);
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 { OTPRequestConsent, OTPRequestResult } from '../types';
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 { OTPRequestConsent, OTPRequestResult } from '../types';
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