@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
|
@@ -40,7 +40,12 @@ import { authLogger } from '../utils/logger';
|
|
|
40
40
|
import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
41
41
|
|
|
42
42
|
import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
43
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
OTPRequestConsent,
|
|
45
|
+
OTPRequestResult,
|
|
46
|
+
PasswordLoginOptions,
|
|
47
|
+
PasswordLoginResult,
|
|
48
|
+
} from '../types';
|
|
44
49
|
import type { OtpRequestRequest } from '../../_api/generated/_cfg_accounts/types.gen';
|
|
45
50
|
|
|
46
51
|
// The ONLY place route conventions are declared. '/auth' is the sign-in page
|
|
@@ -368,6 +373,98 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
368
373
|
[setStoredEmail, redirectManager, router],
|
|
369
374
|
);
|
|
370
375
|
|
|
376
|
+
// ── Password login flow ────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
// Same envelope, same branches and the same single session owner as
|
|
379
|
+
// verifyOTP — only the credential differs. Anything that diverges here
|
|
380
|
+
// (a second token store, a bespoke fetch) forks the session.
|
|
381
|
+
const login = useCallback(
|
|
382
|
+
async (
|
|
383
|
+
email: string,
|
|
384
|
+
password: string,
|
|
385
|
+
options?: PasswordLoginOptions,
|
|
386
|
+
): Promise<PasswordLoginResult> => {
|
|
387
|
+
const { redirectUrl, skipRedirect, rememberMe = false } = options || {};
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
// The caller opting out of redirect commits to handling ALL navigation
|
|
391
|
+
// for this login — including the 2FA continuation and success screen.
|
|
392
|
+
pendingManualNavRef.current = Boolean(skipRedirect);
|
|
393
|
+
|
|
394
|
+
const result = await accountsRef.current.loginWithPassword({
|
|
395
|
+
email,
|
|
396
|
+
password,
|
|
397
|
+
remember_me: rememberMe,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (result.requires_2fa && result.session_id) {
|
|
401
|
+
authLogger.info('2FA required, session:', result.session_id);
|
|
402
|
+
return {
|
|
403
|
+
success: true,
|
|
404
|
+
message: 'Password verified, 2FA required',
|
|
405
|
+
requires_2fa: true,
|
|
406
|
+
session_id: result.session_id,
|
|
407
|
+
should_prompt_2fa: result.should_prompt_2fa,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!result.access || !result.refresh) {
|
|
412
|
+
authLogger.error('Password login returned invalid response:', result);
|
|
413
|
+
return { success: false, message: 'Invalid login response' };
|
|
414
|
+
}
|
|
415
|
+
setStoredEmail(email);
|
|
416
|
+
|
|
417
|
+
Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
|
|
418
|
+
category: AnalyticsCategory.AUTH,
|
|
419
|
+
label: 'password',
|
|
420
|
+
});
|
|
421
|
+
if (result.user?.id) {
|
|
422
|
+
Analytics.setUser(String(result.user.id));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!skipRedirect) {
|
|
426
|
+
// Priority: saved back-url (per-visit intent from the guard) >
|
|
427
|
+
// explicit redirectUrl (static app default) > config default.
|
|
428
|
+
// hardPush = full page load so every context reinitializes cleanly.
|
|
429
|
+
const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
|
|
430
|
+
if (saved) redirectManager.clearRedirect();
|
|
431
|
+
const finalRedirectUrl = saved || redirectUrl || routesRef.current.defaultCallback;
|
|
432
|
+
authLogger.info('Redirecting after auth to:', finalRedirectUrl);
|
|
433
|
+
router.hardPush(finalRedirectUrl);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
success: true,
|
|
438
|
+
message: 'Login successful',
|
|
439
|
+
user: result.user as UserProfile,
|
|
440
|
+
should_prompt_2fa: result.should_prompt_2fa,
|
|
441
|
+
};
|
|
442
|
+
} catch (error) {
|
|
443
|
+
authLogger.error('Password login error:', error);
|
|
444
|
+
Analytics.event(AnalyticsEvent.AUTH_PASSWORD_LOGIN_FAIL, {
|
|
445
|
+
category: AnalyticsCategory.AUTH,
|
|
446
|
+
label: 'password',
|
|
447
|
+
});
|
|
448
|
+
if (error instanceof APIError) {
|
|
449
|
+
// 429 carries `retry_after`; 401 carries only `error_code`. Both are
|
|
450
|
+
// surfaced so a form can show a countdown instead of a dead message.
|
|
451
|
+
const body = error.response as
|
|
452
|
+
| { error_code?: string; retry_after?: number }
|
|
453
|
+
| undefined;
|
|
454
|
+
return {
|
|
455
|
+
success: false,
|
|
456
|
+
statusCode: error.statusCode,
|
|
457
|
+
message: apiErrorMessage(error),
|
|
458
|
+
errorCode: body?.error_code,
|
|
459
|
+
retryAfter: typeof body?.retry_after === 'number' ? body.retry_after : undefined,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return { success: false, message: 'Failed to sign in' };
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
[setStoredEmail, redirectManager, router],
|
|
466
|
+
);
|
|
467
|
+
|
|
371
468
|
// ── Session actions ────────────────────────────────────────────────────────
|
|
372
469
|
|
|
373
470
|
const refreshToken = useCallback(async (): Promise<{ success: boolean; message: string }> => {
|
|
@@ -442,6 +539,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
442
539
|
clearSavedEmail: clearStoredEmail,
|
|
443
540
|
requestOTP,
|
|
444
541
|
verifyOTP,
|
|
542
|
+
login,
|
|
445
543
|
refreshToken,
|
|
446
544
|
logout,
|
|
447
545
|
saveRedirectUrl: redirectManager.setRedirect,
|
|
@@ -464,6 +562,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
464
562
|
clearStoredEmail,
|
|
465
563
|
requestOTP,
|
|
466
564
|
verifyOTP,
|
|
565
|
+
login,
|
|
467
566
|
refreshToken,
|
|
468
567
|
logout,
|
|
469
568
|
redirectManager,
|
|
@@ -522,6 +621,10 @@ const defaultAuthState: AuthContextType = {
|
|
|
522
621
|
authLogger.warn('useAuth: verifyOTP called outside AuthProvider');
|
|
523
622
|
return { success: false, message: 'AuthProvider not available' };
|
|
524
623
|
},
|
|
624
|
+
login: async () => {
|
|
625
|
+
authLogger.warn('useAuth: login called outside AuthProvider');
|
|
626
|
+
return { success: false, message: 'AuthProvider not available' };
|
|
627
|
+
},
|
|
525
628
|
refreshToken: async () => {
|
|
526
629
|
authLogger.warn('useAuth: refreshToken called outside AuthProvider');
|
|
527
630
|
return { success: false, message: 'AuthProvider not available' };
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
3
|
import type { User } from '../../';
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
OTPRequestConsent,
|
|
6
|
+
OTPRequestResult,
|
|
7
|
+
PasswordLoginOptions,
|
|
8
|
+
PasswordLoginResult,
|
|
9
|
+
} from '../types';
|
|
5
10
|
|
|
6
11
|
// User profile type
|
|
7
12
|
export type UserProfile = User;
|
|
@@ -58,6 +63,10 @@ export interface AuthContextType {
|
|
|
58
63
|
session_id?: string | null;
|
|
59
64
|
should_prompt_2fa?: boolean;
|
|
60
65
|
}>;
|
|
66
|
+
// Password Method - same envelope and the same session owner as verifyOTP;
|
|
67
|
+
// `requires_2fa` hands off to the existing 2FA flow with a session id.
|
|
68
|
+
login: (email: string, password: string, options?: PasswordLoginOptions) => Promise<PasswordLoginResult>;
|
|
69
|
+
|
|
61
70
|
refreshToken: () => Promise<{ success: boolean; message: string }>;
|
|
62
71
|
logout: () => void;
|
|
63
72
|
|
|
@@ -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,
|
package/src/auth/types/form.ts
CHANGED
|
@@ -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;
|
package/src/auth/types/index.ts
CHANGED
|
@@ -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',
|
package/src/auth/utils/guard.ts
CHANGED
|
@@ -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
|
|