@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.
- package/README.md +25 -0
- package/dist/auth.cjs +339 -73
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +106 -2
- package/dist/auth.d.ts +106 -2
- package/dist/auth.mjs +339 -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__/guard.test.ts +65 -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
- package/src/auth/utils/guard.ts +27 -1
- 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
|
-
|
|
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
|
|