@djangocfg/layouts 2.1.621 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.621",
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.621",
95
- "@djangocfg/api": "^2.1.621",
96
- "@djangocfg/devtools": "^2.1.621",
97
- "@djangocfg/i18n": "^2.1.621",
98
- "@djangocfg/ui-core": "^2.1.621",
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.621",
127
- "@djangocfg/api": "^2.1.621",
128
- "@djangocfg/devtools": "^2.1.621",
129
- "@djangocfg/i18n": "^2.1.621",
130
- "@djangocfg/typescript-config": "^2.1.621",
131
- "@djangocfg/ui-core": "^2.1.621",
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 && <div className="auth-oauth-reserve" aria-hidden="true" />}
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
- height: calc(2 * var(--auth-oauth-height) + var(--auth-oauth-gap) + var(--auth-gap) + 1rem);
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. */
@@ -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
+ });
@@ -18,6 +18,27 @@ import {
18
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
  /**
@@ -57,6 +78,7 @@ export function useAuthGuard({
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
@@ -83,6 +105,12 @@ export function useAuthGuard({
83
105
  useEffect(() => {
84
106
  if (shouldRedirectToAuth(guardState)) {
85
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
+ }
86
114
  authTrace('guard.redirect', { from: window.location.pathname, to: resolvedAuthPath });
87
115
  saveRedirectUrl(currentUrl);
88
116
  setIsRedirecting(true);
@@ -92,7 +120,11 @@ export function useAuthGuard({
92
120
  }, [mounted, requireAuth, isAuthenticated, authLoading, isRedirecting, router, saveRedirectUrl, resolvedAuthPath]);
93
121
 
94
122
  const isLoading = resolveGuardIsLoading(guardState);
95
- const loadingText = isRedirecting ? 'Redirecting to login...' : 'Authenticating...';
123
+ const loadingText = looping
124
+ ? 'Sign-in loop stopped. Open the console and run __cfgAuth.dump()'
125
+ : isRedirecting
126
+ ? 'Redirecting to login...'
127
+ : 'Authenticating...';
96
128
 
97
129
  return {
98
130
  isLoading,