@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
@@ -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
 
@@ -43,6 +43,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
43
43
  const {
44
44
  requestOTP,
45
45
  verifyOTP,
46
+ login,
46
47
  getSavedEmail,
47
48
  saveEmail,
48
49
  } = useAuth();
@@ -51,6 +52,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
51
52
  const {
52
53
  identifier,
53
54
  otp,
55
+ password,
54
56
  acceptedTerms,
55
57
  marketingConsent,
56
58
  rememberMe,
@@ -235,6 +237,87 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
235
237
  await submitOTP(identifier, otp);
236
238
  }, [identifier, otp, submitOTP]);
237
239
 
240
+ // ─────────────────────────────────────────────────────────────────────────
241
+ // Password Submit
242
+ // ─────────────────────────────────────────────────────────────────────────
243
+
244
+ // The password credential lands on the SAME step machine as the OTP code:
245
+ // both end at '2fa' or 'success', so the layout renders one set of screens.
246
+ const submitPassword = useCallback(async (
247
+ submitIdentifier: string,
248
+ submitPassword_: string,
249
+ ): Promise<boolean> => {
250
+ if (!submitIdentifier || !validateIdentifier(submitIdentifier)) {
251
+ const msg = 'Please enter a valid email address';
252
+ setError(msg);
253
+ onError?.(msg);
254
+ return false;
255
+ }
256
+
257
+ if (!submitPassword_) {
258
+ const msg = 'Please enter your password';
259
+ setError(msg);
260
+ onError?.(msg);
261
+ return false;
262
+ }
263
+
264
+ setIsLoading(true);
265
+ clearError();
266
+
267
+ try {
268
+ // Skip redirect - we'll show success screen first
269
+ const result = await login(submitIdentifier, submitPassword_, {
270
+ redirectUrl,
271
+ skipRedirect: true,
272
+ rememberMe,
273
+ });
274
+
275
+ if (result.requires_2fa && result.session_id) {
276
+ authLogger.info('2FA required after password login');
277
+ setTwoFactorSessionId(result.session_id);
278
+ setShouldPrompt2FA(result.should_prompt_2fa || false);
279
+ setStep('2fa');
280
+ saveIdentifierToStorage(submitIdentifier);
281
+ return true; // Password was accepted, now need 2FA
282
+ }
283
+
284
+ if (result.success) {
285
+ saveIdentifierToStorage(submitIdentifier);
286
+ authLogger.info('Password login successful, showing success screen');
287
+ setStep('success');
288
+ onOTPSuccess?.();
289
+ return true;
290
+ }
291
+
292
+ // A lockout (429) carries a retry-after; show the countdown instead of a
293
+ // message the user can only re-trigger.
294
+ if (result.retryAfter) {
295
+ startRateLimitCountdown(result.retryAfter);
296
+ clearError();
297
+ } else {
298
+ setError(result.message);
299
+ onError?.(result.message);
300
+ }
301
+ return false;
302
+ } catch {
303
+ const msg = 'An unexpected error occurred';
304
+ setError(msg);
305
+ onError?.(msg);
306
+ return false;
307
+ } finally {
308
+ setIsLoading(false);
309
+ }
310
+ }, [
311
+ login, validateIdentifier, saveIdentifierToStorage, setError, setIsLoading,
312
+ clearError, startRateLimitCountdown, onOTPSuccess, onError, redirectUrl,
313
+ rememberMe, setTwoFactorSessionId, setShouldPrompt2FA, setStep,
314
+ ]);
315
+
316
+ const handlePasswordSubmit = useCallback(async (e: React.FormEvent) => {
317
+ e.preventDefault();
318
+ await submitPassword(identifier, password);
319
+ }, [identifier, password, submitPassword]);
320
+
238
321
  const handleResendOTP = useCallback(async () => {
239
322
  setIsLoading(true);
240
323
  clearError();
@@ -370,6 +453,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
370
453
  // Submit handlers
371
454
  handleIdentifierSubmit,
372
455
  handleOTPSubmit,
456
+ handlePasswordSubmit,
373
457
  handleResendOTP,
374
458
  handleBackToIdentifier,
375
459
  forceOTPStep,
@@ -21,6 +21,7 @@ export const useAuthFormState = (
21
21
  ): UseAuthFormStateReturn => {
22
22
  const [identifier, setIdentifier] = useState(initialIdentifier);
23
23
  const [otp, setOtp] = useState('');
24
+ const [password, setPassword] = useState('');
24
25
  const [isLoading, setIsLoading] = useState(false);
25
26
  const [acceptedTerms, setAcceptedTerms] = useState(true);
26
27
  // null until the marketing-consent feature (if enabled) resolves its
@@ -66,6 +67,7 @@ export const useAuthFormState = (
66
67
  // State
67
68
  identifier,
68
69
  otp,
70
+ password,
69
71
  isLoading,
70
72
  acceptedTerms,
71
73
  marketingConsent,
@@ -83,6 +85,7 @@ export const useAuthFormState = (
83
85
  // Handlers
84
86
  setIdentifier,
85
87
  setOtp,
88
+ setPassword,
86
89
  setAcceptedTerms,
87
90
  setMarketingConsent,
88
91
  setRememberMe,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { MutableRefObject } from 'react';
9
9
 
10
- import type { WebmailLink } from '../../_api/generated/types.gen';
10
+ import type { User, WebmailLink } from '../../_api/generated/types.gen';
11
11
 
12
12
  export type { WebmailLink };
13
13
 
@@ -41,6 +41,41 @@ export interface OTPRequestConsent {
41
41
  disclosureVersion: string;
42
42
  }
43
43
 
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Password Login Types
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ export interface PasswordLoginOptions {
49
+ /** URL to redirect to after a successful login (ignored when skipRedirect). */
50
+ redirectUrl?: string;
51
+ /** Caller owns ALL navigation for this login, including the 2FA continuation. */
52
+ skipRedirect?: boolean;
53
+ /** Ask the server for a 30-day session instead of the default lifetime. */
54
+ rememberMe?: boolean;
55
+ }
56
+
57
+ /**
58
+ * Result of a password login.
59
+ *
60
+ * Deliberately the same shape as the verifyOTP result plus the failure codes
61
+ * the password endpoint adds: a sign-in form branches on `requires_2fa` the
62
+ * same way whichever credential it collected.
63
+ */
64
+ export interface PasswordLoginResult {
65
+ success: boolean;
66
+ message: string;
67
+ user?: User;
68
+ requires_2fa?: boolean;
69
+ session_id?: string | null;
70
+ should_prompt_2fa?: boolean;
71
+ /** HTTP status of a failure — 401 for bad credentials, 429 when throttled. */
72
+ statusCode?: number;
73
+ /** Machine-readable failure code, e.g. `invalid_credentials`, `locked_out`. */
74
+ errorCode?: string;
75
+ /** Seconds until a throttled caller may retry (429 only). */
76
+ retryAfter?: number;
77
+ }
78
+
44
79
  // ─────────────────────────────────────────────────────────────────────────────
45
80
  // Form State
46
81
  // ─────────────────────────────────────────────────────────────────────────────
@@ -50,6 +85,8 @@ export interface AuthFormState {
50
85
  identifier: string;
51
86
  /** OTP code input */
52
87
  otp: string;
88
+ /** Password input (password sign-in; empty for the OTP-only flow) */
89
+ password: string;
53
90
  /** Loading state */
54
91
  isLoading: boolean;
55
92
  /** Terms acceptance state */
@@ -91,6 +128,7 @@ export interface AuthFormState {
91
128
  export interface AuthFormStateHandlers {
92
129
  setIdentifier: (identifier: string) => void;
93
130
  setOtp: (otp: string) => void;
131
+ setPassword: (password: string) => void;
94
132
  setAcceptedTerms: (accepted: boolean) => void;
95
133
  setMarketingConsent: (consent: boolean | null) => void;
96
134
  setRememberMe: (rememberMe: boolean) => void;
@@ -111,6 +149,8 @@ export interface AuthFormStateHandlers {
111
149
  export interface AuthFormSubmitHandlers {
112
150
  handleIdentifierSubmit: (e: React.FormEvent) => Promise<void>;
113
151
  handleOTPSubmit: (e: React.FormEvent) => Promise<void>;
152
+ /** Submit email + password against the password grant */
153
+ handlePasswordSubmit: (e: React.FormEvent) => Promise<void>;
114
154
  handleResendOTP: () => Promise<void>;
115
155
  handleBackToIdentifier: () => void;
116
156
  forceOTPStep: () => void;
@@ -14,6 +14,8 @@ export type {
14
14
  UseAuthFormOptions,
15
15
  OTPRequestResult,
16
16
  OTPRequestConsent,
17
+ PasswordLoginOptions,
18
+ PasswordLoginResult,
17
19
  WebmailLink,
18
20
  } from './form';
19
21
 
@@ -19,6 +19,7 @@ export enum AnalyticsEvent {
19
19
  AUTH_OTP_REQUEST = 'auth_otp_request',
20
20
  AUTH_LOGIN_SUCCESS = 'auth_login_success',
21
21
  AUTH_OTP_VERIFY_FAIL = 'auth_otp_verify_fail',
22
+ AUTH_PASSWORD_LOGIN_FAIL = 'auth_password_login_fail',
22
23
  AUTH_SESSION_EXPIRED = 'auth_session_expired',
23
24
  AUTH_TOKEN_REFRESH = 'auth_token_refresh',
24
25
  AUTH_TOKEN_REFRESH_FAIL = 'auth_token_refresh_fail',
@@ -9,7 +9,9 @@
9
9
  *
10
10
  * Invariant that fixes the bug: once auth is resolved (`authLoading === false`)
11
11
  * and the user is unauthenticated, `isLoading` must become false so the guard
12
- * redirects to the form instead of spinning forever.
12
+ * redirects to the form instead of spinning forever. The same invariant covers
13
+ * the forbidden case: a signed-in user without the required privilege must stop
14
+ * loading too, or the preloader spins on a state no redirect will ever clear.
13
15
  */
14
16
 
15
17
  export interface GuardInput {
@@ -23,6 +25,29 @@ export interface GuardInput {
23
25
  isRedirecting: boolean;
24
26
  /** User currently has a (valid-looking) session. */
25
27
  isAuthenticated: boolean;
28
+ /** Route requires staff/admin privileges, not just a session. */
29
+ requireStaff?: boolean;
30
+ /** `is_staff || is_superuser`, from `useAuth().isAdminUser`. */
31
+ isAdminUser?: boolean;
32
+ }
33
+
34
+ /**
35
+ * True when the session is valid but lacks the required privilege.
36
+ *
37
+ * Distinct from unauthenticated: the fix is a different account, not signing
38
+ * in, so this must never redirect to the auth form. Redirecting would also
39
+ * loop — the guard saves the current URL first, so a successful re-login on
40
+ * the same account returns straight to the wall.
41
+ */
42
+ export function isForbidden(s: GuardInput): boolean {
43
+ return Boolean(
44
+ s.mounted &&
45
+ s.requireAuth &&
46
+ s.requireStaff &&
47
+ !s.authLoading &&
48
+ s.isAuthenticated &&
49
+ !s.isAdminUser
50
+ );
26
51
  }
27
52
 
28
53
  /**
@@ -32,6 +57,7 @@ export interface GuardInput {
32
57
  export function resolveGuardIsLoading(s: GuardInput): boolean {
33
58
  if (!s.mounted) return true; // pre-hydration: always preloader
34
59
  if (!s.requireAuth) return false; // public route: never blocks
60
+ if (isForbidden(s)) return false; // resolved: render the wall, don't spin
35
61
  return s.authLoading || s.isRedirecting || !s.isAuthenticated;
36
62
  }
37
63
 
@@ -4,6 +4,7 @@ export {
4
4
  resolveGuardIsLoading,
5
5
  shouldRedirectToAuth,
6
6
  resolveGuardIsAuthenticated,
7
+ isForbidden,
7
8
  type GuardInput,
8
9
  } from './guard';
9
10
  export { normalizePath, isAllowedAuthPath } from './path';