@djangocfg/api 2.1.546 → 2.1.548

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 (44) hide show
  1. package/README.md +25 -0
  2. package/dist/auth.cjs +339 -73
  3. package/dist/auth.cjs.map +1 -1
  4. package/dist/auth.d.cts +106 -2
  5. package/dist/auth.d.ts +106 -2
  6. package/dist/auth.mjs +339 -73
  7. package/dist/auth.mjs.map +1 -1
  8. package/dist/clients.cjs +18 -0
  9. package/dist/clients.cjs.map +1 -1
  10. package/dist/clients.d.cts +96 -0
  11. package/dist/clients.d.ts +96 -0
  12. package/dist/clients.mjs +18 -0
  13. package/dist/clients.mjs.map +1 -1
  14. package/dist/hooks.cjs.map +1 -1
  15. package/dist/hooks.mjs.map +1 -1
  16. package/dist/index.cjs +18 -0
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +171 -3
  19. package/dist/index.d.ts +171 -3
  20. package/dist/index.mjs +18 -0
  21. package/dist/index.mjs.map +1 -1
  22. package/package.json +2 -2
  23. package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
  24. package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenCreate.ts +64 -0
  25. package/src/_api/generated/_cfg_accounts/openapi.json +157 -0
  26. package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginError.ts +13 -0
  27. package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginRequest.ts +13 -0
  28. package/src/_api/generated/_cfg_accounts/schemas/PasswordLoginResponse.ts +18 -0
  29. package/src/_api/generated/_cfg_accounts/schemas/index.ts +3 -0
  30. package/src/_api/generated/openapi.json +157 -0
  31. package/src/_api/generated/sdk.gen.ts +20 -1
  32. package/src/_api/generated/types.gen.ts +149 -0
  33. package/src/auth/__tests__/guard.test.ts +65 -0
  34. package/src/auth/__tests__/passwordLogin.test.tsx +239 -0
  35. package/src/auth/context/AccountsContext.tsx +32 -7
  36. package/src/auth/context/AuthContext.tsx +104 -1
  37. package/src/auth/context/types.ts +10 -1
  38. package/src/auth/hooks/useAuthForm.ts +84 -0
  39. package/src/auth/hooks/useAuthFormState.ts +3 -0
  40. package/src/auth/types/form.ts +41 -1
  41. package/src/auth/types/index.ts +2 -0
  42. package/src/auth/utils/analytics.ts +1 -0
  43. package/src/auth/utils/guard.ts +27 -1
  44. package/src/auth/utils/index.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;
@@ -15,6 +15,7 @@ import {
15
15
  resolveGuardIsLoading,
16
16
  resolveGuardIsAuthenticated,
17
17
  shouldRedirectToAuth,
18
+ isForbidden,
18
19
  type GuardInput,
19
20
  } from '../utils/guard';
20
21
 
@@ -101,6 +102,70 @@ describe('safety-net timeout (effectiveAuthLoading = authLoading && !timedOut)',
101
102
  });
102
103
  });
103
104
 
105
+ /**
106
+ * Staff gate. A signed-in non-staff user reaching a staff-only shell is NOT an
107
+ * auth failure: signing in again cannot fix it. The guard must resolve to a
108
+ * distinct "forbidden" state — never a redirect (which loops, since the guard
109
+ * saves the current URL first) and never a spinner (the original hang bug).
110
+ */
111
+ describe('isForbidden (staff gate)', () => {
112
+ const staff = (o: Partial<GuardInput>): GuardInput =>
113
+ s({ requireStaff: true, isAdminUser: false, ...o });
114
+
115
+ it('signed-in non-staff user on a staff route is forbidden', () => {
116
+ expect(isForbidden(staff({}))).toBe(true);
117
+ });
118
+
119
+ it('staff user passes the gate', () => {
120
+ expect(isForbidden(staff({ isAdminUser: true }))).toBe(false);
121
+ });
122
+
123
+ it('a superuser who is not is_staff passes (isAdminUser folds both flags)', () => {
124
+ // useAuth() computes isAdminUser as is_staff || is_superuser, so the guard
125
+ // only ever sees the fold. Checking is_staff alone here would hide the
126
+ // surface from the people most likely to need it.
127
+ expect(isForbidden(staff({ isAdminUser: true }))).toBe(false);
128
+ });
129
+
130
+ it('is not forbidden while auth is still resolving (role unknown yet)', () => {
131
+ expect(isForbidden(staff({ authLoading: true }))).toBe(false);
132
+ });
133
+
134
+ it('is not forbidden before mount (SSR never knows the role)', () => {
135
+ expect(isForbidden(staff({ mounted: false }))).toBe(false);
136
+ });
137
+
138
+ it('unauthenticated is a redirect, not a forbidden wall', () => {
139
+ expect(isForbidden(staff({ isAuthenticated: false }))).toBe(false);
140
+ expect(shouldRedirectToAuth(staff({ isAuthenticated: false }))).toBe(true);
141
+ });
142
+
143
+ it('routes that do not require staff are never forbidden', () => {
144
+ expect(isForbidden(s({ requireStaff: false, isAdminUser: false }))).toBe(false);
145
+ expect(isForbidden(base)).toBe(false); // requireStaff undefined
146
+ });
147
+
148
+ it('a public route is never forbidden', () => {
149
+ expect(isForbidden(staff({ requireAuth: false }))).toBe(false);
150
+ });
151
+
152
+ it('REGRESSION: a forbidden user stops loading (renders the wall, never spins)', () => {
153
+ expect(resolveGuardIsLoading(staff({}))).toBe(false);
154
+ });
155
+
156
+ it('REGRESSION: a forbidden user never triggers a redirect (no login loop)', () => {
157
+ expect(shouldRedirectToAuth(staff({}))).toBe(false);
158
+ });
159
+
160
+ it('a staff user on a staff route renders content', () => {
161
+ expect(resolveGuardIsLoading(staff({ isAdminUser: true }))).toBe(false);
162
+ });
163
+
164
+ it('still blocks while a staff route is resolving auth', () => {
165
+ expect(resolveGuardIsLoading(staff({ authLoading: true }))).toBe(true);
166
+ });
167
+ });
168
+
104
169
  describe('resolveGuardIsAuthenticated', () => {
105
170
  it('is true on a public route regardless of session', () => {
106
171
  expect(resolveGuardIsAuthenticated({ requireAuth: false, isAuthenticated: false })).toBe(true);
@@ -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