@djangocfg/layouts 2.1.619 → 2.1.622
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/package.json +12 -12
- package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +6 -1
- package/src/layouts/AuthLayout/styles/oauth.css +22 -1
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +4 -2
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.test.ts +20 -0
- package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +44 -25
- package/src/testing/MockAuthProvider.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.622",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"test": "vitest run"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/analytics": "^2.1.
|
|
95
|
-
"@djangocfg/api": "^2.1.
|
|
96
|
-
"@djangocfg/devtools": "^2.1.
|
|
97
|
-
"@djangocfg/i18n": "^2.1.
|
|
98
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/analytics": "^2.1.622",
|
|
95
|
+
"@djangocfg/api": "^2.1.622",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.622",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.622",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.622",
|
|
99
99
|
"@hookform/resolvers": "^5.2.2 || ^7.0.2",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^1.68.0",
|
|
@@ -123,12 +123,12 @@
|
|
|
123
123
|
"qrcode.react": "^4.2.0"
|
|
124
124
|
},
|
|
125
125
|
"devDependencies": {
|
|
126
|
-
"@djangocfg/analytics": "^2.1.
|
|
127
|
-
"@djangocfg/api": "^2.1.
|
|
128
|
-
"@djangocfg/devtools": "^2.1.
|
|
129
|
-
"@djangocfg/i18n": "^2.1.
|
|
130
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
131
|
-
"@djangocfg/ui-core": "^2.1.
|
|
126
|
+
"@djangocfg/analytics": "^2.1.622",
|
|
127
|
+
"@djangocfg/api": "^2.1.622",
|
|
128
|
+
"@djangocfg/devtools": "^2.1.622",
|
|
129
|
+
"@djangocfg/i18n": "^2.1.622",
|
|
130
|
+
"@djangocfg/typescript-config": "^2.1.622",
|
|
131
|
+
"@djangocfg/ui-core": "^2.1.622",
|
|
132
132
|
"@storybook/react-vite": "^10.6.5",
|
|
133
133
|
"@types/node": "^25.9.5",
|
|
134
134
|
"@types/react": "19.3.0",
|
|
@@ -52,7 +52,12 @@ function OAuthSectionRaw({ sourceUrl, disabled, onError, onFocusEmail }: OAuthSe
|
|
|
52
52
|
<AuthDivider />
|
|
53
53
|
</>
|
|
54
54
|
)}
|
|
55
|
-
{!oauth.isReady &&
|
|
55
|
+
{!oauth.isReady && (
|
|
56
|
+
<div className="auth-oauth-reserve" aria-hidden="true">
|
|
57
|
+
<span className="auth-oauth-skeleton" />
|
|
58
|
+
<span className="auth-oauth-skeleton" />
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
56
61
|
</>
|
|
57
62
|
);
|
|
58
63
|
}
|
|
@@ -20,7 +20,28 @@
|
|
|
20
20
|
/* Holds the room of two buttons, the gap and the divider while the server's
|
|
21
21
|
provider list loads, so the email field does not jump when it arrives. */
|
|
22
22
|
.auth-oauth-reserve {
|
|
23
|
-
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
gap: var(--auth-oauth-gap);
|
|
26
|
+
/* Two buttons plus the divider's room below them. */
|
|
27
|
+
padding-bottom: calc(var(--auth-gap) + 1rem);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* A quiet placeholder of one provider button, so the held room reads as "loading", not as a hole. */
|
|
31
|
+
.auth-oauth-skeleton {
|
|
32
|
+
display: block;
|
|
33
|
+
height: var(--auth-oauth-height);
|
|
34
|
+
border-radius: var(--auth-radius, 0.75rem);
|
|
35
|
+
background: color-mix(in srgb, var(--auth-muted, currentColor) 12%, transparent);
|
|
36
|
+
animation: auth-oauth-pulse 1.4s ease-in-out infinite;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@keyframes auth-oauth-pulse {
|
|
40
|
+
50% { opacity: 0.5; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@media (prefers-reduced-motion: reduce) {
|
|
44
|
+
.auth-oauth-skeleton { animation: none; }
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
/* The line for whoever deploys the site, under a notice about configuration. */
|
|
@@ -102,12 +102,14 @@ export function PrivateLayout({
|
|
|
102
102
|
|
|
103
103
|
if (isLoading) {
|
|
104
104
|
return (
|
|
105
|
+
// The auth gate covers the whole viewport: solid, above the chat dock (z-100), sheets, dialogs and popovers.
|
|
106
|
+
// Nothing behind it may show or take a click while the session is still being decided.
|
|
105
107
|
<Preloader
|
|
106
108
|
variant="fullscreen"
|
|
107
109
|
text={loadingText}
|
|
108
110
|
size="lg"
|
|
109
|
-
backdrop={
|
|
110
|
-
|
|
111
|
+
backdrop={false}
|
|
112
|
+
className="z-[9996] bg-background"
|
|
111
113
|
/>
|
|
112
114
|
);
|
|
113
115
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { redirectLoopDetected, resetRedirectLoop } from './useAuthGuard';
|
|
4
|
+
|
|
5
|
+
describe('guard redirect circuit breaker', () => {
|
|
6
|
+
beforeEach(resetRedirectLoop);
|
|
7
|
+
|
|
8
|
+
it('allows a few redirects', () => {
|
|
9
|
+
for (let i = 0; i < 5; i++) expect(redirectLoopDetected(1000 + i * 400)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('trips on the sixth redirect inside the window', () => {
|
|
13
|
+
for (let i = 0; i < 5; i++) redirectLoopDetected(1000 + i * 400);
|
|
14
|
+
expect(redirectLoopDetected(3500)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('forgets redirects older than the window (a slow, legitimate sequence never trips)', () => {
|
|
18
|
+
for (let i = 0; i < 20; i++) expect(redirectLoopDetected(i * 6000)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -9,15 +9,36 @@
|
|
|
9
9
|
import { useEffect, useState } from 'react';
|
|
10
10
|
import { useRouter } from 'next/navigation';
|
|
11
11
|
|
|
12
|
-
import { auth } from '@djangocfg/api';
|
|
13
12
|
import {
|
|
14
13
|
useAuth,
|
|
15
14
|
resolveGuardIsLoading,
|
|
16
15
|
resolveGuardIsAuthenticated,
|
|
17
16
|
shouldRedirectToAuth,
|
|
18
17
|
isForbidden,
|
|
18
|
+
authTrace,
|
|
19
19
|
} from '@djangocfg/api/auth';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Circuit breaker. A redirect that never lands (the sign-in page sends the user straight back) would otherwise
|
|
23
|
+
* run forever and hide the cause. After LOOP_MAX redirects within LOOP_WINDOW_MS the guard stops redirecting,
|
|
24
|
+
* writes `guard.loop` to the auth trace and shows a message that names the way to read it.
|
|
25
|
+
*/
|
|
26
|
+
const LOOP_MAX = 5;
|
|
27
|
+
const LOOP_WINDOW_MS = 5000;
|
|
28
|
+
const redirectTimes: number[] = [];
|
|
29
|
+
|
|
30
|
+
/** @internal exported for tests */
|
|
31
|
+
export function resetRedirectLoop(): void {
|
|
32
|
+
redirectTimes.length = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @internal exported for tests */
|
|
36
|
+
export function redirectLoopDetected(now: number): boolean {
|
|
37
|
+
redirectTimes.push(now);
|
|
38
|
+
while (redirectTimes.length && now - (redirectTimes[0] ?? now) > LOOP_WINDOW_MS) redirectTimes.shift();
|
|
39
|
+
return redirectTimes.length > LOOP_MAX;
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
interface UseAuthGuardOptions {
|
|
22
43
|
requireAuth?: boolean;
|
|
23
44
|
/**
|
|
@@ -28,10 +49,8 @@ interface UseAuthGuardOptions {
|
|
|
28
49
|
/** Explicit override; defaults to the app's resolved `useAuth().routes.auth`. */
|
|
29
50
|
authPath?: string;
|
|
30
51
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* an endless preloader if AuthContext initialization ever wedges (a hung
|
|
34
|
-
* request, a CSP-swallowed response, an unresolved promise). Default 8s.
|
|
52
|
+
* @deprecated No effect. The provider owns the single safety timer (8 s), so
|
|
53
|
+
* `status` always leaves 'resolving'. Kept so existing callers still compile.
|
|
35
54
|
*/
|
|
36
55
|
loadingTimeoutMs?: number;
|
|
37
56
|
}
|
|
@@ -51,12 +70,15 @@ export function useAuthGuard({
|
|
|
51
70
|
requireAuth = true,
|
|
52
71
|
requireStaff = false,
|
|
53
72
|
authPath,
|
|
54
|
-
loadingTimeoutMs = 8000,
|
|
55
73
|
}: UseAuthGuardOptions): UseAuthGuardResult {
|
|
56
|
-
const {
|
|
74
|
+
const { status, isAdminUser, saveRedirectUrl, routes } = useAuth();
|
|
75
|
+
// One linear state decides everything: 'resolving' → spinner, 'authenticated' → render, 'anonymous' → redirect.
|
|
76
|
+
const isAuthenticated = status === 'authenticated';
|
|
77
|
+
const authLoading = status === 'resolving';
|
|
57
78
|
const resolvedAuthPath = authPath ?? routes.auth;
|
|
58
79
|
const router = useRouter();
|
|
59
80
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
|
81
|
+
const [looping, setLooping] = useState(false);
|
|
60
82
|
|
|
61
83
|
// Auth state lives in the browser (storage), so the server always renders
|
|
62
84
|
// unauthenticated → the preloader. Keep the first client render identical to
|
|
@@ -66,26 +88,14 @@ export function useAuthGuard({
|
|
|
66
88
|
const [mounted, setMounted] = useState(false);
|
|
67
89
|
useEffect(() => setMounted(true), []);
|
|
68
90
|
|
|
69
|
-
// Safety-net timeout: force auth to be considered "resolved" if it hasn't
|
|
70
|
-
// settled within loadingTimeoutMs, so the guard can act (render or redirect)
|
|
71
|
-
// instead of spinning forever. Only armed while actually loading.
|
|
72
|
-
const [authTimedOut, setAuthTimedOut] = useState(false);
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (!mounted || !authLoading || authTimedOut) return;
|
|
75
|
-
const t = setTimeout(() => setAuthTimedOut(true), loadingTimeoutMs);
|
|
76
|
-
return () => clearTimeout(t);
|
|
77
|
-
}, [mounted, authLoading, authTimedOut, loadingTimeoutMs]);
|
|
78
|
-
|
|
79
91
|
// Guard state derives from a single pure resolver (see @djangocfg/api/auth
|
|
80
92
|
// utils/guard) so the "spinner vs redirect vs render" decision is unit-tested
|
|
81
93
|
// in isolation and can't silently regress into the "stuck on Authenticating…"
|
|
82
94
|
// state where a resolved-but-unauthenticated session never redirects.
|
|
83
|
-
// `authLoading` is forced false once the safety-net timeout fires.
|
|
84
|
-
const effectiveAuthLoading = authLoading && !authTimedOut;
|
|
85
95
|
const guardState = {
|
|
86
96
|
mounted,
|
|
87
97
|
requireAuth,
|
|
88
|
-
authLoading
|
|
98
|
+
authLoading,
|
|
89
99
|
isRedirecting,
|
|
90
100
|
isAuthenticated,
|
|
91
101
|
requireStaff,
|
|
@@ -93,19 +103,28 @@ export function useAuthGuard({
|
|
|
93
103
|
};
|
|
94
104
|
|
|
95
105
|
useEffect(() => {
|
|
96
|
-
|
|
97
|
-
// full-page load right after sign-in). Never redirect on that: the snapshot catches up and the effect re-runs.
|
|
98
|
-
if (shouldRedirectToAuth(guardState) && !auth.isAuthenticated()) {
|
|
106
|
+
if (shouldRedirectToAuth(guardState)) {
|
|
99
107
|
const currentUrl = window.location.pathname + window.location.search;
|
|
108
|
+
if (redirectLoopDetected(Date.now())) {
|
|
109
|
+
authTrace('guard.loop', { from: window.location.pathname, to: resolvedAuthPath, status });
|
|
110
|
+
setLooping(true);
|
|
111
|
+
setIsRedirecting(true);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
authTrace('guard.redirect', { from: window.location.pathname, to: resolvedAuthPath });
|
|
100
115
|
saveRedirectUrl(currentUrl);
|
|
101
116
|
setIsRedirecting(true);
|
|
102
117
|
router.push(resolvedAuthPath);
|
|
103
118
|
}
|
|
104
119
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
-
}, [mounted, requireAuth, isAuthenticated,
|
|
120
|
+
}, [mounted, requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, resolvedAuthPath]);
|
|
106
121
|
|
|
107
122
|
const isLoading = resolveGuardIsLoading(guardState);
|
|
108
|
-
const loadingText =
|
|
123
|
+
const loadingText = looping
|
|
124
|
+
? 'Sign-in loop stopped. Open the console and run __cfgAuth.dump()'
|
|
125
|
+
: isRedirecting
|
|
126
|
+
? 'Redirecting to login...'
|
|
127
|
+
: 'Authenticating...';
|
|
109
128
|
|
|
110
129
|
return {
|
|
111
130
|
isLoading,
|
|
@@ -63,6 +63,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
|
|
|
63
63
|
const value = useMemo<AuthContextType>(() => ({
|
|
64
64
|
user,
|
|
65
65
|
isLoading,
|
|
66
|
+
status: isLoading ? 'resolving' : (isAuthenticated ?? Boolean(user)) ? 'authenticated' : 'anonymous',
|
|
66
67
|
isAuthenticated: isAuthenticated ?? Boolean(user),
|
|
67
68
|
isAdminUser,
|
|
68
69
|
routes: { auth: '/auth', defaultCallback: '/', defaultAuthCallback: '/auth', ...routes },
|