@djangocfg/layouts 2.1.606 → 2.1.608

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 (58) hide show
  1. package/README.md +4 -0
  2. package/package.json +12 -12
  3. package/src/components/account/AccountInline.tsx +122 -0
  4. package/src/components/account/AccountMenu.test.tsx +110 -0
  5. package/src/components/account/AccountMenu.tsx +117 -0
  6. package/src/components/account/accountContext.ts +14 -0
  7. package/src/components/account/index.ts +13 -0
  8. package/src/components/account/triggers.tsx +100 -0
  9. package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
  10. package/src/components/account/useAccountModel.tsx +76 -0
  11. package/src/components/errors/AppErrorFallback.test.tsx +33 -0
  12. package/src/components/errors/AppErrorFallback.tsx +42 -0
  13. package/src/components/errors/ErrorBoundary.tsx +4 -41
  14. package/src/components/errors/ErrorLayout.tsx +17 -1
  15. package/src/components/errors/README.md +6 -1
  16. package/src/components/errors/chunkReload.test.ts +89 -0
  17. package/src/components/errors/index.ts +2 -0
  18. package/src/layouts/AppLayout/BaseApp.tsx +10 -55
  19. package/src/layouts/AppLayout/index.ts +1 -0
  20. package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +17 -11
  21. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +1 -1
  22. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +1 -4
  23. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +17 -3
  24. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +21 -25
  25. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +2 -2
  26. package/src/layouts/AuthLayout/styles/oauth.css +45 -0
  27. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
  28. package/src/layouts/PrivateLayout/README.md +23 -5
  29. package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
  30. package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +230 -0
  31. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +86 -19
  32. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
  33. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
  34. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
  35. package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
  36. package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
  37. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
  38. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
  39. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +13 -8
  40. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +2 -2
  41. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
  42. package/src/layouts/PrivateLayout/context.tsx +15 -3
  43. package/src/layouts/PrivateLayout/geometry.test.ts +44 -0
  44. package/src/layouts/PrivateLayout/geometry.ts +40 -16
  45. package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
  46. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
  47. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
  48. package/src/layouts/PrivateLayout/index.ts +2 -0
  49. package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
  50. package/src/layouts/PrivateLayout/types.ts +27 -2
  51. package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
  52. package/src/layouts/PublicLayout/components/index.ts +2 -2
  53. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
  54. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
  55. package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
  56. package/src/shared/ProjectContext.tsx +18 -0
  57. package/src/testing/MockAuthProvider.tsx +5 -2
  58. /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What the account menu shows, shared by every place it can live (sidebar footer, top bar avatar): the user, the
3
+ * display fields and the menu items. One source, so the menus can never drift apart.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+
10
+ import { useAuth } from '@djangocfg/api/auth';
11
+ import type { MenuItem } from '@djangocfg/ui-core/components';
12
+
13
+ import type { UserMenuGroup, UserMenuLocaleConfig } from '../../layouts/types';
14
+ import { useAccountMenuItems } from './useAccountMenuItems';
15
+
16
+ export interface AccountView {
17
+ source: 'user' | 'dev-fallback';
18
+ displayName: string;
19
+ email: string | null;
20
+ avatarUrl: string;
21
+ plan: string | null;
22
+ }
23
+
24
+ export interface UseAccountModelOptions {
25
+ /** App link groups. */
26
+ groups?: UserMenuGroup[];
27
+ /** Plan shown after the name in the footer trigger. */
28
+ plan?: string | null;
29
+ i18n?: UserMenuLocaleConfig;
30
+ /** Extra items between the groups and Sign out (language, app links). */
31
+ extra?: MenuItem[];
32
+ /** Settings rows in the menu. @default true */
33
+ settings?: boolean;
34
+ /** Identity block above the items: the `email` (default) or a name + email `card`. */
35
+ identity?: 'email' | 'card';
36
+ }
37
+
38
+ export function useAccountModel({ groups, plan = null, i18n, settings = true, extra, identity = 'email' }: UseAccountModelOptions = {}) {
39
+ const { user } = useAuth();
40
+
41
+ /**
42
+ * When `useAuth` returns a real user we use it; otherwise (dev only) a placeholder keeps the menu reachable for
43
+ * debugging. Production stays strict: callers render nothing without a user.
44
+ */
45
+ const account = React.useMemo<AccountView>(() => {
46
+ if (user) {
47
+ return {
48
+ source: 'user',
49
+ displayName: user.display_username || user.email || 'User',
50
+ email: user.email ?? null,
51
+ avatarUrl: user.avatar ?? '',
52
+ plan,
53
+ };
54
+ }
55
+ return {
56
+ source: 'dev-fallback',
57
+ displayName: 'Guest (dev)',
58
+ email: null,
59
+ avatarUrl: '',
60
+ plan: plan ?? 'No session',
61
+ };
62
+ }, [user, plan]);
63
+
64
+ const headerLabel: React.ReactNode =
65
+ identity === 'card' ? (
66
+ <div className="flex flex-col space-y-1">
67
+ <p className="text-sm font-medium leading-none">{account.displayName}</p>
68
+ <p className="text-xs leading-none text-muted-foreground">{account.email}</p>
69
+ </div>
70
+ ) : (
71
+ account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null)
72
+ );
73
+ const menuItems = useAccountMenuItems({ groups, label: headerLabel, i18n, settings, extra });
74
+
75
+ return { user, account, menuItems };
76
+ }
@@ -0,0 +1,33 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ import { AppErrorFallback } from './AppErrorFallback';
7
+
8
+ vi.mock('@djangocfg/i18n', () => ({
9
+ getT: (key: string) => key,
10
+ }));
11
+
12
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ function render(props = {}) {
15
+ const host = document.createElement('div');
16
+ act(() => createRoot(host).render(<AppErrorFallback {...props} />));
17
+ return host;
18
+ }
19
+
20
+ describe('AppErrorFallback', () => {
21
+ it('renders exactly one action button', () => {
22
+ const host = render();
23
+ const buttons = host.querySelectorAll('button');
24
+ expect(buttons).toHaveLength(1);
25
+ expect(buttons[0]!.textContent).toBe('layouts.errors.refreshPage');
26
+ });
27
+
28
+ it('keeps one button when a support email is shown', () => {
29
+ const host = render({ supportEmail: 'help@example.com' });
30
+ expect(host.querySelectorAll('button')).toHaveLength(1);
31
+ expect(host.querySelector('a[href="mailto:help@example.com"]')).not.toBeNull();
32
+ });
33
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * AppErrorFallback — the one localized crash screen for Next app layouts.
3
+ *
4
+ * Exactly ONE action: "Refresh Page" (hard reload). A soft `reset()` re-mounts
5
+ * the same broken tree and usually crashes again, so it is not offered as a
6
+ * second near-identical button. Used by `BaseApp` (via UiProviders) and
7
+ * `ErrorBoundary`.
8
+ */
9
+
10
+ 'use client';
11
+
12
+ import { getT } from '@djangocfg/i18n';
13
+
14
+ export interface AppErrorFallbackProps {
15
+ supportEmail?: string;
16
+ }
17
+
18
+ export function AppErrorFallback({ supportEmail }: AppErrorFallbackProps) {
19
+ return (
20
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
21
+ <div className="max-w-md w-full space-y-4 text-center">
22
+ <h1 className="text-2xl font-bold text-foreground">{getT('layouts.errors.somethingWentWrong')}</h1>
23
+ <p className="text-muted-foreground">{getT('layouts.errors.tryRefreshing')}</p>
24
+ {supportEmail && (
25
+ <p className="text-sm text-muted-foreground">
26
+ {getT('layouts.errors.persistsContact', { email: '' })}{' '}
27
+ <a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
28
+ {supportEmail}
29
+ </a>
30
+ </p>
31
+ )}
32
+ <button
33
+ type="button"
34
+ onClick={() => window.location.reload()}
35
+ className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
36
+ >
37
+ {getT('layouts.errors.refreshPage')}
38
+ </button>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
@@ -13,7 +13,7 @@
13
13
  import { useMemo, type ComponentType, type ErrorInfo, type ReactNode } from 'react';
14
14
 
15
15
  import { Boundary, type BoundaryRenderProps } from '@djangocfg/ui-core/components';
16
- import { getT } from '@djangocfg/i18n';
16
+ import { AppErrorFallback } from './AppErrorFallback';
17
17
 
18
18
  interface ErrorBoundaryProps {
19
19
  children: ReactNode;
@@ -21,50 +21,13 @@ interface ErrorBoundaryProps {
21
21
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
22
22
  }
23
23
 
24
- /** Localized fullscreen fallback. `reset()` re-mounts the tree (ui-core); we
25
- * also offer a hard reload, matching the prior behavior. */
26
- function makeFallback(supportEmail?: string) {
27
- return function ErrorFallback({ reset }: BoundaryRenderProps) {
28
- const title = getT('layouts.errors.somethingWentWrong');
29
- const description = getT('layouts.errors.tryRefreshing');
30
- const refreshButton = getT('layouts.errors.refreshPage');
31
- return (
32
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
33
- <div className="max-w-md w-full space-y-4 text-center">
34
- <h1 className="text-2xl font-bold text-foreground">{title}</h1>
35
- <p className="text-muted-foreground">{description}</p>
36
- {supportEmail && (
37
- <p className="text-sm text-muted-foreground">
38
- {getT('layouts.errors.persistsContact', { email: '' })}{' '}
39
- <a
40
- href={`mailto:${supportEmail}`}
41
- className="text-primary hover:underline"
42
- >
43
- {supportEmail}
44
- </a>
45
- </p>
46
- )}
47
- <button
48
- onClick={() => {
49
- reset();
50
- // Hard reload as a fallback if a pure reset can't recover.
51
- window.location.reload();
52
- }}
53
- className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
54
- >
55
- {refreshButton}
56
- </button>
57
- </div>
58
- </div>
59
- );
60
- };
61
- }
62
-
63
24
  export function ErrorBoundary({ children, supportEmail, onError }: ErrorBoundaryProps) {
64
25
  // Memoize so the boundary's FallbackComponent identity is stable across
65
26
  // renders (no remount churn).
66
27
  const Fallback = useMemo<ComponentType<BoundaryRenderProps>>(
67
- () => makeFallback(supportEmail),
28
+ () => function ErrorFallback() {
29
+ return <AppErrorFallback supportEmail={supportEmail} />;
30
+ },
68
31
  [supportEmail],
69
32
  );
70
33
  return (
@@ -17,9 +17,10 @@
17
17
 
18
18
  'use client';
19
19
 
20
- import React, { useMemo } from 'react';
20
+ import React, { useEffect, useMemo } from 'react';
21
21
 
22
22
  import { Button } from '@djangocfg/ui-core/components';
23
+ import { canAutoReloadForChunk, isChunkLoadError, reloadForChunkError } from '@djangocfg/ui-core/lib';
23
24
  import { useAppT } from '@djangocfg/i18n';
24
25
 
25
26
  import { getErrorContent } from './errorConfig';
@@ -54,6 +55,14 @@ export interface ErrorLayoutProps {
54
55
  * it finally matters.
55
56
  */
56
57
  statusUrl?: string;
58
+ /**
59
+ * The caught error (`global-error.tsx` / `error.tsx` receive it as `error`).
60
+ * When it is a stale-chunk failure (ChunkLoadError, cancelled `/_next/static`
61
+ * fetch after a deploy) the page reloads ONCE (sessionStorage loop guard) and
62
+ * renders nothing meanwhile; if the reload already happened, the normal error
63
+ * screen is shown.
64
+ */
65
+ error?: unknown;
57
66
  }
58
67
 
59
68
  /**
@@ -82,8 +91,13 @@ export function ErrorLayout({
82
91
  illustration,
83
92
  supportEmail = 'support@example.com',
84
93
  statusUrl,
94
+ error,
85
95
  }: ErrorLayoutProps) {
86
96
  const t = useAppT();
97
+ const staleChunk = error !== undefined && isChunkLoadError(error);
98
+ useEffect(() => {
99
+ if (staleChunk) reloadForChunkError();
100
+ }, [staleChunk]);
87
101
 
88
102
  const labels = useMemo(() => ({
89
103
  goBack: t('layouts.errors.goBack'),
@@ -121,6 +135,8 @@ export function ErrorLayout({
121
135
  window.location.href = '/';
122
136
  };
123
137
 
138
+ if (staleChunk && canAutoReloadForChunk()) return null;
139
+
124
140
  return (
125
141
  <div className="min-h-screen flex items-center justify-center px-4 bg-background">
126
142
  <div className="max-w-2xl w-full text-center space-y-8">
@@ -11,11 +11,16 @@ All boundaries here build on the single `Boundary` primitive in
11
11
 
12
12
  | File | What it does |
13
13
  |------|--------------|
14
- | `ErrorBoundary.tsx` | Thin wrapper over ui-core `Boundary` — i18n fallback + support email (for direct use; `BaseApp` no longer wraps it). |
14
+ | `AppErrorFallback.tsx` | The one localized crash screen: title, text, support email, ONE "Refresh Page" button (hard reload). Used by `BaseApp` and `ErrorBoundary`. |
15
+ | `ErrorBoundary.tsx` | Thin wrapper over ui-core `Boundary` — renders `AppErrorFallback` (for direct use; `BaseApp` no longer wraps it). |
15
16
  | `MonitorBoundary.tsx` | ui-core `Boundary` that also reports to `@djangocfg/devtools`. |
16
17
  | `ErrorLayout.tsx` | Full-page error layout (`getErrorContent` / `ERROR_CODES`). |
17
18
  | `ErrorsTracker/` | Global runtime tracker — listens for error CustomEvents and shows toasts. |
18
19
 
20
+ ## Stale chunks after a deploy
21
+
22
+ A chunk-load failure (ChunkLoadError, "Loading chunk N failed", dynamic import failure, cancelled fetch of `/_next/static/...`) is a stale tab, not a crash: the page reloads ONCE instead of showing the error screen. Logic lives in `@djangocfg/ui-core/lib` (`chunk-reload.ts`), wired in `Boundary` (render errors), `UiProviders` (window `error` + `unhandledrejection`) and `ErrorLayout error={error}` (`global-error.tsx`). Loop guard: `sessionStorage` key `djangocfg:chunk-reload-at`, 30 s window; with storage blocked no auto reload. Tests: `chunkReload.test.ts`, `AppErrorFallback.test.tsx`.
23
+
19
24
  ## ErrorLayout — `statusUrl`
20
25
 
21
26
  `ErrorLayout` takes an optional `statusUrl`, rendered as a secondary link beside
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import {
5
+ CHUNK_RELOAD_STORAGE_KEY,
6
+ CHUNK_RELOAD_WINDOW_MS,
7
+ canAutoReloadForChunk,
8
+ installChunkReloadListeners,
9
+ isChunkLoadError,
10
+ isStaticAssetLoadEvent,
11
+ reloadForChunkError,
12
+ } from '@djangocfg/ui-core/lib';
13
+
14
+ describe('isChunkLoadError', () => {
15
+ it.each([
16
+ ['ChunkLoadError by name', Object.assign(new Error('x'), { name: 'ChunkLoadError' })],
17
+ ['webpack JS chunk', new Error('Loading chunk 123 failed.\n(error: /_next/static/chunks/123.js)')],
18
+ ['webpack CSS chunk', new Error('Loading CSS chunk 45 failed.')],
19
+ ['Chrome dynamic import', new TypeError('Failed to fetch dynamically imported module: /_next/static/a.js')],
20
+ ['Firefox dynamic import', new TypeError('error loading dynamically imported module')],
21
+ ['Safari module script', new TypeError('Importing a module script failed.')],
22
+ ['cancelled fetch of static asset', new TypeError('Failed to fetch /_next/static/chunks/app/page-abc.js')],
23
+ ['aborted static asset via stack', Object.assign(new Error('signal is aborted'), { stack: 'at https://x.io/_next/static/chunks/1.js:1' })],
24
+ ['string reason', 'Loading chunk 9 failed.'],
25
+ ])('detects %s', (_n, err) => expect(isChunkLoadError(err)).toBe(true));
26
+
27
+ it.each([
28
+ ['ordinary error', new Error('Cannot read properties of undefined')],
29
+ ['API fetch failure', new TypeError('Failed to fetch https://api.example.com/items')],
30
+ ['null', null],
31
+ ['undefined', undefined],
32
+ ])('ignores %s', (_n, err) => expect(isChunkLoadError(err)).toBe(false));
33
+ });
34
+
35
+ describe('isStaticAssetLoadEvent', () => {
36
+ it('matches failed script/link under /_next/static and nothing else', () => {
37
+ const script = document.createElement('script');
38
+ script.src = 'https://x.io/_next/static/chunks/1.js';
39
+ const other = document.createElement('script');
40
+ other.src = 'https://cdn.io/lib.js';
41
+ const link = document.createElement('link');
42
+ link.href = 'https://x.io/_next/static/css/a.css';
43
+ const mk = (target: Element) => Object.defineProperty(new Event('error'), 'target', { value: target });
44
+ expect(isStaticAssetLoadEvent(mk(script))).toBe(true);
45
+ expect(isStaticAssetLoadEvent(mk(link))).toBe(true);
46
+ expect(isStaticAssetLoadEvent(mk(other))).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('reload guard', () => {
51
+ const reload = vi.fn();
52
+ beforeEach(() => {
53
+ sessionStorage.clear();
54
+ reload.mockClear();
55
+ Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload } });
56
+ });
57
+ afterEach(() => vi.restoreAllMocks());
58
+
59
+ it('reloads once, then blocks a second reload inside the window', () => {
60
+ expect(reloadForChunkError(1_000)).toBe(true);
61
+ expect(sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1000');
62
+ expect(reloadForChunkError(1_000 + 5_000)).toBe(false);
63
+ expect(reload).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('allows another reload after the window passes', () => {
67
+ reloadForChunkError(1_000);
68
+ expect(canAutoReloadForChunk(1_000 + CHUNK_RELOAD_WINDOW_MS + 1)).toBe(true);
69
+ });
70
+
71
+ it('never reloads when sessionStorage is unusable (no loop risk)', () => {
72
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
73
+ throw new Error('denied');
74
+ });
75
+ expect(canAutoReloadForChunk()).toBe(false);
76
+ expect(reloadForChunkError()).toBe(false);
77
+ expect(reload).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('window listeners reload once for an unhandled chunk rejection', () => {
81
+ const off = installChunkReloadListeners();
82
+ const ev = new Event('unhandledrejection') as Event & { reason?: unknown };
83
+ ev.reason = new Error('Loading chunk 7 failed.');
84
+ window.dispatchEvent(ev);
85
+ window.dispatchEvent(ev);
86
+ expect(reload).toHaveBeenCalledTimes(1);
87
+ off();
88
+ });
89
+ });
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  export { ErrorBoundary } from './ErrorBoundary';
6
+ export { AppErrorFallback } from './AppErrorFallback';
7
+ export type { AppErrorFallbackProps } from './AppErrorFallback';
6
8
  export { MonitorBoundary } from './MonitorBoundary';
7
9
  export type { MonitorBoundaryProps } from './MonitorBoundary';
8
10
  export { ErrorLayout } from './ErrorLayout';
@@ -54,65 +54,20 @@ import { useScrollToTop } from '@djangocfg/ui-core/hooks';
54
54
  import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
55
55
  import { NextIntlLinkBridge } from './NextIntlLinkBridge';
56
56
  import { ThemeProvider } from '@djangocfg/ui-core/theme';
57
- import type { BoundaryRenderProps } from '@djangocfg/ui-core/components';
58
- import { getT } from '@djangocfg/i18n';
57
+ import { AppErrorFallback } from '../../components/errors/AppErrorFallback';
59
58
  import { ErrorTrackingProvider } from '../../components/errors/ErrorsTracker';
60
59
  import { AnalyticsProvider } from '../../snippets/Analytics';
61
60
  import { AuthDialog } from '../../snippets/AuthDialog';
62
61
 
63
62
  import type { BaseLayoutProps } from '../types/layout.types';
64
63
  import { LayoutI18nProvider } from '../../shared/LayoutI18nProvider';
64
+ import { ProjectNameProvider } from '../../shared/ProjectContext';
65
65
 
66
66
  import React from 'react';
67
67
 
68
68
  // For backwards compatibility, re-export as BaseAppProps
69
69
  export type BaseAppProps = BaseLayoutProps;
70
70
 
71
- /**
72
- * Localized crash fallback fed into UiProviders' built-in Boundary. Renders the
73
- * i18n'd "something went wrong" screen + optional support email. `reset()`
74
- * re-mounts (soft recover); the Reload button hard-reloads when a soft reset
75
- * can't recover (e.g. the crash is in a provider above the boundary's subtree).
76
- */
77
- function AppErrorFallback({
78
- reset,
79
- supportEmail,
80
- }: BoundaryRenderProps & { supportEmail?: string }) {
81
- const title = getT('layouts.errors.somethingWentWrong');
82
- const description = getT('layouts.errors.tryRefreshing');
83
- const retry = getT('layouts.errors.refreshPage');
84
- return (
85
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
86
- <div className="max-w-md w-full space-y-4 text-center">
87
- <h1 className="text-2xl font-bold text-foreground">{title}</h1>
88
- <p className="text-muted-foreground">{description}</p>
89
- {supportEmail && (
90
- <p className="text-sm text-muted-foreground">
91
- {getT('layouts.errors.persistsContact', { email: '' })}{' '}
92
- <a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
93
- {supportEmail}
94
- </a>
95
- </p>
96
- )}
97
- <div className="flex items-center justify-center gap-2">
98
- <button
99
- onClick={reset}
100
- className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
101
- >
102
- {retry}
103
- </button>
104
- <button
105
- onClick={() => window.location.reload()}
106
- className="rounded-md border border-border px-4 py-2 text-foreground hover:bg-muted"
107
- >
108
- {getT('layouts.errors.refreshPage')}
109
- </button>
110
- </div>
111
- </div>
112
- </div>
113
- );
114
- }
115
-
116
71
  /**
117
72
  * Lands every forward navigation at the top of the page.
118
73
  *
@@ -189,9 +144,7 @@ export function BaseApp({
189
144
  onError={errorBoundary?.onError}
190
145
  errorFallback={
191
146
  enableErrorBoundary
192
- ? (props) => (
193
- <AppErrorFallback {...props} supportEmail={errorBoundary?.supportEmail} />
194
- )
147
+ ? () => <AppErrorFallback supportEmail={errorBoundary?.supportEmail} />
195
148
  : undefined
196
149
  }
197
150
  >
@@ -214,11 +167,13 @@ export function BaseApp({
214
167
  <ScrollToTop />
215
168
  <Devtools {...devtoolsProps} />
216
169
  <LayoutI18nProvider value={i18n}>
217
- {i18n?.routing ? (
218
- <NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
219
- ) : (
220
- children
221
- )}
170
+ <ProjectNameProvider value={project}>
171
+ {i18n?.routing ? (
172
+ <NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
173
+ ) : (
174
+ children
175
+ )}
176
+ </ProjectNameProvider>
222
177
  </LayoutI18nProvider>
223
178
  <NextTopLoader
224
179
  color="var(--primary)"
@@ -13,6 +13,7 @@ export type { BaseAppProps } from './BaseApp';
13
13
 
14
14
  export type { I18nLayoutConfig } from '../types';
15
15
 
16
+ export { ProjectNameProvider, useProjectName } from '../../shared/ProjectContext';
16
17
  export {
17
18
  LayoutI18nProvider,
18
19
  useLayoutI18n,
@@ -1,10 +1,13 @@
1
1
  'use client';
2
2
 
3
- import React, { memo, useCallback } from 'react';
3
+ import React, { memo, useCallback, useEffect } from 'react';
4
+
5
+ import { isOAuthProviderId } from '@djangocfg/api/auth';
4
6
 
5
7
  import { useAuthFormContext } from '../../context';
6
8
  import { AuthDivider } from '../shared/AuthDivider';
7
9
  import { OAuthButtons } from './buttons/OAuthButtons';
10
+ import { describeOAuthFailure } from './errors/errorCopy';
8
11
  import { OAuthNotice } from './notice/OAuthNotice';
9
12
 
10
13
  export interface OAuthSectionProps {
@@ -13,8 +16,8 @@ export interface OAuthSectionProps {
13
16
  disabled?: boolean;
14
17
  /** A provider sign-in that could not start: the sentence to show under the form field. */
15
18
  onError?: (message: string) => void;
16
- /** Called after the notice closed because the visitor chose email: put the cursor in the email field. */
17
- onUseEmail: () => void;
19
+ /** Called when a notice appears whose only way forward is email: put the cursor in the email field. */
20
+ onFocusEmail: () => void;
18
21
  }
19
22
 
20
23
  /**
@@ -25,20 +28,23 @@ export interface OAuthSectionProps {
25
28
  * the list loads it holds the room the buttons will take, so the email field
26
29
  * does not jump; with no providers it renders nothing.
27
30
  */
28
- function OAuthSectionRaw({ sourceUrl, disabled, onError, onUseEmail }: OAuthSectionProps) {
31
+ function OAuthSectionRaw({ sourceUrl, disabled, onError, onFocusEmail }: OAuthSectionProps) {
29
32
  const { oauth, oauthNotice, setOAuthNotice } = useAuthFormContext();
30
33
 
31
34
  const dismissNotice = useCallback(() => setOAuthNotice(null), [setOAuthNotice]);
32
- const useEmail = useCallback(() => {
33
- setOAuthNotice(null);
34
- onUseEmail();
35
- }, [setOAuthNotice, onUseEmail]);
35
+
36
+ // The email field is right below: when the notice says email is the way
37
+ // forward (nothing to retry), the cursor goes there instead of a button.
38
+ useEffect(() => {
39
+ if (!oauthNotice) return;
40
+ const action = describeOAuthFailure(oauthNotice.code)?.action;
41
+ // A retry for a provider this client has no button for is email too.
42
+ if (action === 'email' || (action === 'retry' && !isOAuthProviderId(oauthNotice.provider))) onFocusEmail();
43
+ }, [oauthNotice, onFocusEmail]);
36
44
 
37
45
  return (
38
46
  <>
39
- {oauthNotice && (
40
- <OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} onUseEmail={useEmail} />
41
- )}
47
+ {oauthNotice && <OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} />}
42
48
 
43
49
  {oauth.providers.length > 0 && (
44
50
  <>
@@ -19,7 +19,7 @@ export const OAUTH_ERROR_KEYS = {
19
19
 
20
20
  export type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
21
21
 
22
- /** The one next step a notice offers: reveal the email field, run the provider again, or nothing the visitor can do. */
22
+ /** The next step a notice points at: the email field below (no button; focus moves there), a retry button, or nothing the visitor can do. */
23
23
  export type OAuthNoticeAction = 'email' | 'retry' | 'none';
24
24
 
25
25
  interface Rule {
@@ -12,12 +12,10 @@ export interface OAuthNoticeProps {
12
12
  sourceUrl?: string;
13
13
  /** Closes the notice (button or Escape). */
14
14
  onDismiss: () => void;
15
- /** Takes the visitor to the email field; the notice closes with it. */
16
- onUseEmail: () => void;
17
15
  }
18
16
 
19
17
  /** The failure notice with its retry wired to `useOAuthLogin`. */
20
- function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoticeProps) {
18
+ function OAuthNoticeRaw({ failure, sourceUrl, onDismiss }: OAuthNoticeProps) {
21
19
  const { start, isLoading } = useOAuthLogin({ sourceUrl });
22
20
  const { provider } = failure;
23
21
 
@@ -33,7 +31,6 @@ function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoti
33
31
  onRetry={isOAuthProviderId(provider) ? handleRetry : undefined}
34
32
  retrying={isLoading}
35
33
  onDismiss={onDismiss}
36
- onUseEmail={onUseEmail}
37
34
  />
38
35
  );
39
36
  }
@@ -16,7 +16,7 @@ function render(props: Partial<OAuthNoticeViewProps> = {}) {
16
16
  const host = document.createElement('div');
17
17
  document.body.append(host);
18
18
  const root = createRoot(host);
19
- const handlers = { onDismiss: vi.fn(), onUseEmail: vi.fn(), onRetry: vi.fn() };
19
+ const handlers = { onDismiss: vi.fn(), onRetry: vi.fn() };
20
20
  act(() => root.render(<OAuthNoticeView provider="github" code="invalid_state" {...handlers} {...props} />));
21
21
  return { host, ...handlers, buttons: () => [...host.querySelectorAll('button')] };
22
22
  }
@@ -37,10 +37,24 @@ describe('OAuthNoticeView', () => {
37
37
  expect(onRetry).toHaveBeenCalledOnce();
38
38
  });
39
39
 
40
- it('offers email instead of a retry when the provider has no button', () => {
40
+ it('offers no retry when the provider has no button, and never a button for email', () => {
41
41
  const { buttons } = render({ onRetry: undefined });
42
42
  expect(buttons().some((b) => b.textContent!.includes('notice.retry'))).toBe(false);
43
- expect(buttons().some((b) => b.textContent === 'layouts.auth.oauth.notice.useEmail')).toBe(true);
43
+ expect(render({ code: 'link_not_allowed' }).buttons().map((b) => b.getAttribute('aria-label'))).toEqual([
44
+ 'layouts.auth.oauth.notice.dismiss',
45
+ ]);
46
+ });
47
+
48
+ it('uses one layout for every kind of notice: icon, text, close', () => {
49
+ for (const code of ['invalid_state', 'link_not_allowed', 'origin_not_allowed', 'rate_limited'] as const) {
50
+ const { host } = render({ code, origin: 'https://app.example.com' });
51
+ const notice = host.querySelector('.auth-oauth-notice')!;
52
+ expect(notice.querySelector('.auth-oauth-notice-icon')).not.toBeNull();
53
+ expect(notice.querySelector('.auth-oauth-notice-text .auth-oauth-notice-body')).not.toBeNull();
54
+ expect(notice.querySelector('.auth-oauth-notice-close')!.getAttribute('aria-label')).toBe(
55
+ 'layouts.auth.oauth.notice.dismiss',
56
+ );
57
+ }
44
58
  });
45
59
 
46
60
  it('closes with the dismiss button and with Escape', () => {