@djangocfg/layouts 2.1.605 → 2.1.606

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 (37) hide show
  1. package/package.json +12 -12
  2. package/src/layouts/AuthLayout/AuthLayout.tsx +1 -0
  3. package/src/layouts/AuthLayout/README.md +20 -3
  4. package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +54 -0
  5. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.test.tsx +52 -0
  6. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.tsx +49 -0
  7. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtons.tsx +44 -0
  8. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.test.tsx +56 -0
  9. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.tsx +51 -0
  10. package/src/layouts/AuthLayout/components/oauth/{OAuthCallback.tsx → callback/OAuthCallback.tsx} +7 -33
  11. package/src/layouts/AuthLayout/components/oauth/callback/OAuthCallbackOverlay.tsx +41 -0
  12. package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.test.ts → callback/callbackParams.test.ts} +1 -1
  13. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.test.ts +63 -0
  14. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +95 -0
  15. package/src/layouts/AuthLayout/components/oauth/errors/useOAuthErrorText.ts +25 -0
  16. package/src/layouts/AuthLayout/components/oauth/index.ts +29 -4
  17. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +41 -0
  18. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +64 -0
  19. package/src/layouts/AuthLayout/components/oauth/{OAuthNotice.tsx → notice/OAuthNoticeView.tsx} +26 -32
  20. package/src/layouts/AuthLayout/components/oauth/{GithubIcon.tsx → providers/icons/GithubIcon.tsx} +0 -1
  21. package/src/layouts/AuthLayout/components/oauth/{GoogleIcon.tsx → providers/icons/GoogleIcon.tsx} +1 -1
  22. package/src/layouts/AuthLayout/components/oauth/providers/index.ts +2 -0
  23. package/src/layouts/AuthLayout/components/oauth/providers/registry.test.ts +23 -0
  24. package/src/layouts/AuthLayout/components/oauth/providers/registry.ts +32 -0
  25. package/src/layouts/AuthLayout/components/oauth/{oauthProviders.test.ts → providers/resolveProviders.test.ts} +1 -1
  26. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +1 -2
  27. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +4 -26
  28. package/src/layouts/AuthLayout/context.tsx +1 -1
  29. package/src/layouts/AuthLayout/styles/auth.css +3 -70
  30. package/src/layouts/AuthLayout/styles/oauth.css +33 -0
  31. package/src/testing/MockAuthFormProvider.tsx +1 -1
  32. package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +0 -82
  33. package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +0 -23
  34. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +0 -36
  35. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +0 -86
  36. /package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.ts → callback/callbackParams.ts} +0 -0
  37. /package/src/layouts/AuthLayout/components/oauth/{oauthProviders.ts → providers/resolveProviders.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.605",
3
+ "version": "2.1.606",
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.605",
95
- "@djangocfg/api": "^2.1.605",
96
- "@djangocfg/devtools": "^2.1.605",
97
- "@djangocfg/i18n": "^2.1.605",
98
- "@djangocfg/ui-core": "^2.1.605",
94
+ "@djangocfg/analytics": "^2.1.606",
95
+ "@djangocfg/api": "^2.1.606",
96
+ "@djangocfg/devtools": "^2.1.606",
97
+ "@djangocfg/i18n": "^2.1.606",
98
+ "@djangocfg/ui-core": "^2.1.606",
99
99
  "@hookform/resolvers": "^5.2.2",
100
100
  "consola": "^3.4.2",
101
101
  "lucide-react": "^0.545.0",
@@ -125,12 +125,12 @@
125
125
  "uuid": "^11.1.1"
126
126
  },
127
127
  "devDependencies": {
128
- "@djangocfg/analytics": "^2.1.605",
129
- "@djangocfg/api": "^2.1.605",
130
- "@djangocfg/devtools": "^2.1.605",
131
- "@djangocfg/i18n": "^2.1.605",
132
- "@djangocfg/typescript-config": "^2.1.605",
133
- "@djangocfg/ui-core": "^2.1.605",
128
+ "@djangocfg/analytics": "^2.1.606",
129
+ "@djangocfg/api": "^2.1.606",
130
+ "@djangocfg/devtools": "^2.1.606",
131
+ "@djangocfg/i18n": "^2.1.606",
132
+ "@djangocfg/typescript-config": "^2.1.606",
133
+ "@djangocfg/ui-core": "^2.1.606",
134
134
  "@storybook/react-vite": "^10.5.0",
135
135
  "@types/node": "^25.9.5",
136
136
  "@types/react": "19.2.15",
@@ -29,6 +29,7 @@ import { AuthFormProvider, useAuthFormContext } from './context';
29
29
  import { AuthShell } from './shells';
30
30
 
31
31
  import './styles/auth.css';
32
+ import './styles/oauth.css';
32
33
  import './styles/centered-shell.css';
33
34
  import './styles/split-shell.css';
34
35
  import './styles/fullsplit-shell.css';
@@ -293,10 +293,10 @@ the wait for the code exchange covers the page.
293
293
  | Expired or foreign state, exchange or token failure, provider down, unknown | one short sentence | **Try {provider} again** (email when the provider has no button here) |
294
294
  | Email missing or unverified, domain refused | one sentence | **Continue with email** |
295
295
  | Account disabled, rate limited | one sentence | none |
296
+ | `origin_not_allowed`, `missing_redirect_uri`, `oauth_disabled` (site not set up for the provider) | "not available on this site"; for `origin_not_allowed` also a developer line with the refused origin and the redirect URI to register | **Continue with email** |
296
297
 
297
- The notice closes with its button or Escape. The code-to-copy table
298
- (`OAUTH_CODE_TO_KEY`) and the code-to-next-step table live together in
299
- `components/oauth/oauthNoticeCopy.ts`; copy is `layouts.auth.oauth.error.*` and
298
+ The notice closes with its button or Escape. One table maps each code to its
299
+ sentence and next step: `components/oauth/errors/errorCopy.ts`; copy is `layouts.auth.oauth.error.*` and
300
300
  `layouts.auth.oauth.notice.*` in `@djangocfg/i18n`. The strict-mode guard is
301
301
  unchanged: one attempt per page load.
302
302
 
@@ -333,10 +333,17 @@ AuthLayout/
333
333
  │ ├── context.tsx — Shell context (variant, hasSidebar)
334
334
  │ └── types.ts — AuthShellVariant, AuthBackgroundConfig
335
335
  ├── components/steps/ — Auth step components (IdentifierStep, OTPStep, ...)
336
+ ├── components/oauth/ — Provider sign-in, see its `index.ts` for the map:
337
+ │ ├── providers/ — registry (name + icon per provider), icons, which to show
338
+ │ ├── buttons/ — OAuthProviderButton, OAuthButtonList, OAuthButtons (hook)
339
+ │ ├── notice/ — inline message after a failed round trip
340
+ │ ├── callback/ — /auth?provider=<id>: URL parsing, wait overlay
341
+ │ └── errors/ — failure code -> copy and next step
336
342
  ├── components/shared/ — Reusable UI primitives (AuthButton, AuthConsent, ...)
337
343
  │ incl. AuthBrandPanel — the default `fullsplit` media content
338
344
  ├── styles/
339
345
  │ ├── auth.css — Shared form/button/input styles
346
+ │ ├── oauth.css — Provider column, reserve while loading, developer hint
340
347
  │ ├── centered-shell.css — Centered variant layout
341
348
  │ ├── split-shell.css — Split variant layout
342
349
  │ └── fullsplit-shell.css — Full-bleed split variant layout
@@ -365,3 +372,13 @@ import { useAuthShell } from '@djangocfg/layouts';
365
372
 
366
373
  const { variant, hasSidebar } = useAuthShell();
367
374
  ```
375
+
376
+ ## Adding an OAuth provider
377
+
378
+ 1. The backend adds the provider; `make generate` in `@djangocfg/api` extends `OAuthProviderId`.
379
+ 2. Typecheck now fails in `components/oauth/providers/registry.ts`.
380
+ 3. Draw `providers/icons/<Name>Icon.tsx` (20x20, `currentColor` unless the brand forbids recolouring).
381
+ 4. Add one line to `OAUTH_PROVIDERS`: `{ name: 'Brand', Icon }`.
382
+
383
+ Nothing else changes: the label is `layouts.auth.identifier.oauth.continueWith` with
384
+ the brand name, the notices and error copy read the same table.
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import React, { memo, useCallback } from 'react';
4
+
5
+ import { useAuthFormContext } from '../../context';
6
+ import { AuthDivider } from '../shared/AuthDivider';
7
+ import { OAuthButtons } from './buttons/OAuthButtons';
8
+ import { OAuthNotice } from './notice/OAuthNotice';
9
+
10
+ export interface OAuthSectionProps {
11
+ sourceUrl?: string;
12
+ /** Disables every provider button, e.g. while the email form is submitting. */
13
+ disabled?: boolean;
14
+ /** A provider sign-in that could not start: the sentence to show under the form field. */
15
+ 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;
18
+ }
19
+
20
+ /**
21
+ * Everything provider sign-in puts above the email form, in order: the failure
22
+ * notice (if the last round trip failed), the buttons, the "or" divider.
23
+ *
24
+ * Reads the server's provider list and the notice from the form context. While
25
+ * the list loads it holds the room the buttons will take, so the email field
26
+ * does not jump; with no providers it renders nothing.
27
+ */
28
+ function OAuthSectionRaw({ sourceUrl, disabled, onError, onUseEmail }: OAuthSectionProps) {
29
+ const { oauth, oauthNotice, setOAuthNotice } = useAuthFormContext();
30
+
31
+ const dismissNotice = useCallback(() => setOAuthNotice(null), [setOAuthNotice]);
32
+ const useEmail = useCallback(() => {
33
+ setOAuthNotice(null);
34
+ onUseEmail();
35
+ }, [setOAuthNotice, onUseEmail]);
36
+
37
+ return (
38
+ <>
39
+ {oauthNotice && (
40
+ <OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} onUseEmail={useEmail} />
41
+ )}
42
+
43
+ {oauth.providers.length > 0 && (
44
+ <>
45
+ <OAuthButtons providers={oauth.providers} sourceUrl={sourceUrl} disabled={disabled} onError={onError} />
46
+ <AuthDivider />
47
+ </>
48
+ )}
49
+ {!oauth.isReady && <div className="auth-oauth-reserve" aria-hidden="true" />}
50
+ </>
51
+ );
52
+ }
53
+
54
+ export const OAuthSection = memo(OAuthSectionRaw);
@@ -0,0 +1,52 @@
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 { OAuthButtonList, type OAuthButtonListProps } from './OAuthButtonList';
7
+
8
+ vi.mock('@djangocfg/i18n', () => ({
9
+ useAppT: () => (key: string, params?: Record<string, string>) =>
10
+ key === 'layouts.auth.identifier.oauth.continueWith' ? `Continue with ${params?.provider}` : key,
11
+ }));
12
+
13
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ function render(props: Partial<OAuthButtonListProps> = {}) {
16
+ const host = document.createElement('div');
17
+ const root = createRoot(host);
18
+ const onSelect = vi.fn();
19
+ act(() => root.render(<OAuthButtonList providers={['github', 'google']} onSelect={onSelect} {...props} />));
20
+ return { group: host.querySelector('[role="group"]')!, buttons: [...host.querySelectorAll('button')], onSelect };
21
+ }
22
+
23
+ describe('OAuthButtonList', () => {
24
+ it('renders one named button per provider inside a labelled group', () => {
25
+ const { group, buttons } = render();
26
+ expect(group.getAttribute('aria-label')).toBe('layouts.auth.identifier.oauth.groupLabel');
27
+ expect(buttons.map((b) => b.textContent)).toEqual(['Continue with GitHub', 'Continue with Google']);
28
+ });
29
+
30
+ it('reports the clicked provider', () => {
31
+ const { buttons, onSelect } = render();
32
+ act(() => buttons[1]!.click());
33
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith('google');
34
+ });
35
+
36
+ it('shows progress on one provider and disables the other', () => {
37
+ const { buttons } = render({ loadingProvider: 'github' });
38
+ expect(buttons[0]!.getAttribute('aria-busy')).toBe('true');
39
+ expect(buttons[0]!.disabled).toBe(true);
40
+ expect(buttons[1]!.getAttribute('aria-busy')).toBeNull();
41
+ expect(buttons[1]!.disabled).toBe(true);
42
+ });
43
+
44
+ it('disables everything when told to, and nothing otherwise', () => {
45
+ expect(render({ disabled: true }).buttons.every((b) => b.disabled)).toBe(true);
46
+ expect(render().buttons.every((b) => !b.disabled)).toBe(true);
47
+ });
48
+
49
+ it('renders nothing but the group for an empty list', () => {
50
+ expect(render({ providers: [] }).buttons).toEqual([]);
51
+ });
52
+ });
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import React, { memo } from 'react';
4
+
5
+ import type { OAuthProviderId } from '@djangocfg/api/auth';
6
+ import { useAppT } from '@djangocfg/i18n';
7
+
8
+ import { OAUTH_PROVIDERS } from '../providers/registry';
9
+ import { OAuthProviderButton } from './OAuthProviderButton';
10
+
11
+ export interface OAuthButtonListProps {
12
+ providers: readonly OAuthProviderId[];
13
+ /** The provider whose flow is in flight: it shows progress, the others are disabled. */
14
+ loadingProvider?: OAuthProviderId | null;
15
+ /** Disables every button, e.g. while the email form is submitting. */
16
+ disabled?: boolean;
17
+ onSelect: (provider: OAuthProviderId) => void;
18
+ }
19
+
20
+ /**
21
+ * The provider buttons as a labelled group, one per provider, stacked at full
22
+ * width (a column holds at 320px and in long translations). Stateless: the
23
+ * flow lives in `OAuthButtons`, which keeps this list easy to render in any state.
24
+ */
25
+ function OAuthButtonListRaw({ providers, loadingProvider = null, disabled = false, onSelect }: OAuthButtonListProps) {
26
+ const t = useAppT();
27
+ const busy = loadingProvider !== null;
28
+
29
+ return (
30
+ <div className="auth-oauth" role="group" aria-label={t('layouts.auth.identifier.oauth.groupLabel')}>
31
+ {providers.map((id) => {
32
+ const { name, Icon } = OAUTH_PROVIDERS[id];
33
+ return (
34
+ <OAuthProviderButton
35
+ key={id}
36
+ Icon={Icon}
37
+ label={t('layouts.auth.identifier.oauth.continueWith', { provider: name })}
38
+ data-provider={id}
39
+ loading={loadingProvider === id}
40
+ disabled={disabled || (busy && loadingProvider !== id)}
41
+ onClick={() => onSelect(id)}
42
+ />
43
+ );
44
+ })}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export const OAuthButtonList = memo(OAuthButtonListRaw);
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import React, { memo, useCallback, useRef } from 'react';
4
+
5
+ import { type OAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
6
+
7
+ import { useOAuthErrorText } from '../errors/useOAuthErrorText';
8
+ import { OAuthButtonList } from './OAuthButtonList';
9
+
10
+ export interface OAuthButtonsProps {
11
+ providers: readonly OAuthProviderId[];
12
+ sourceUrl?: string;
13
+ /** Disables every button, e.g. while the email form is submitting. */
14
+ disabled?: boolean;
15
+ /** Receives the visitor-facing sentence for a sign-in that could not start. */
16
+ onError?: (message: string) => void;
17
+ }
18
+
19
+ /**
20
+ * The provider buttons wired to `useOAuthLogin`: a click starts that
21
+ * provider's flow, progress shows on the one clicked and the rest are disabled
22
+ * until the page leaves or the start fails.
23
+ */
24
+ function OAuthButtonsRaw({ providers, sourceUrl, disabled, onError }: OAuthButtonsProps) {
25
+ const errorText = useOAuthErrorText();
26
+ const startedRef = useRef<OAuthProviderId | null>(null);
27
+
28
+ const { start, loadingProvider } = useOAuthLogin({
29
+ sourceUrl,
30
+ onError: (message, code) => onError?.(errorText(code, message, startedRef.current)),
31
+ });
32
+
33
+ const handleSelect = useCallback(
34
+ (id: OAuthProviderId) => {
35
+ startedRef.current = id;
36
+ void start(id);
37
+ },
38
+ [start],
39
+ );
40
+
41
+ return <OAuthButtonList providers={providers} loadingProvider={loadingProvider} disabled={disabled} onSelect={handleSelect} />;
42
+ }
43
+
44
+ export const OAuthButtons = memo(OAuthButtonsRaw);
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { OAUTH_PROVIDERS } from '../providers/registry';
7
+ import { OAuthProviderButton } from './OAuthProviderButton';
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ function render(state: { loading?: boolean; disabled?: boolean } = {}) {
12
+ const host = document.createElement('div');
13
+ const root = createRoot(host);
14
+ act(() =>
15
+ root.render(
16
+ <>
17
+ <OAuthProviderButton Icon={OAUTH_PROVIDERS.github.Icon} label="GitHub" {...state} />
18
+ <OAuthProviderButton Icon={OAUTH_PROVIDERS.google.Icon} label="Google" {...state} />
19
+ </>,
20
+ ),
21
+ );
22
+ return [...host.querySelectorAll('button')];
23
+ }
24
+
25
+ describe('OAuthProviderButton', () => {
26
+ it('gives every provider the same classes', () => {
27
+ const [a, b] = render();
28
+ expect(a!.className).toBe(b!.className);
29
+ expect(a!.className).toContain('hover:bg-accent');
30
+ expect(a!.querySelector('[data-slot="oauth-icon"] svg')).not.toBeNull();
31
+ });
32
+
33
+ it('hides the mark from assistive tech: the label carries the name', () => {
34
+ const [a] = render();
35
+ expect(a!.querySelector('[data-slot="oauth-icon"]')!.getAttribute('aria-hidden')).toBe('true');
36
+ expect(a!.textContent).toBe('GitHub');
37
+ });
38
+
39
+ it('marks loading as busy and disabled, without the provider icon', () => {
40
+ for (const b of render({ loading: true })) {
41
+ expect(b.getAttribute('aria-busy')).toBe('true');
42
+ expect(b.disabled).toBe(true);
43
+ expect(b.querySelector('[data-slot="oauth-icon"]')).toBeNull();
44
+ }
45
+ });
46
+
47
+ it('disables both the same way', () => {
48
+ for (const b of render({ disabled: true })) expect(b.disabled).toBe(true);
49
+ });
50
+
51
+ it('lets a long label wrap instead of clipping it', () => {
52
+ const [a] = render();
53
+ expect(a!.className).toContain('whitespace-normal');
54
+ expect(a!.className).toContain('h-auto');
55
+ });
56
+ });
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import React, { memo } from 'react';
4
+
5
+ import { Button } from '@djangocfg/ui-core/components';
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ export interface OAuthProviderButtonProps
9
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
10
+ /** Provider mark (an svg component); hidden while loading. */
11
+ Icon: React.FC;
12
+ label: string;
13
+ loading?: boolean;
14
+ }
15
+
16
+ /**
17
+ * The one button every OAuth provider uses. Border, height, radius, type,
18
+ * hover, focus, active, disabled and loading are the same for all; only the
19
+ * icon and the label change. Theme tokens only, so it follows light and dark.
20
+ *
21
+ * Full width, at least `--auth-oauth-height` tall (above the 44px touch
22
+ * minimum). The label may wrap to a second line on a narrow screen or in a
23
+ * long translation; the button grows instead of clipping it.
24
+ */
25
+ function OAuthProviderButtonRaw({ Icon, label, loading = false, className, ...props }: OAuthProviderButtonProps) {
26
+ return (
27
+ <Button
28
+ type="button"
29
+ variant="outline"
30
+ loading={loading}
31
+ aria-busy={loading || undefined}
32
+ className={cn(
33
+ 'h-auto min-h-[var(--auth-oauth-height)] w-full gap-2.5 whitespace-normal px-4 py-2 text-center text-sm font-medium shadow-none',
34
+ 'hover:bg-accent hover:text-accent-foreground active:bg-accent/70',
35
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
36
+ 'disabled:opacity-50',
37
+ className,
38
+ )}
39
+ {...props}
40
+ >
41
+ {!loading && (
42
+ <span data-slot="oauth-icon" className="inline-flex shrink-0" aria-hidden="true">
43
+ <Icon />
44
+ </span>
45
+ )}
46
+ {label}
47
+ </Button>
48
+ );
49
+ }
50
+
51
+ export const OAuthProviderButton = memo(OAuthProviderButtonRaw);
@@ -1,19 +1,17 @@
1
1
  'use client';
2
2
 
3
- import { Loader2 } from 'lucide-react';
4
3
  import { useSearchParams } from 'next/navigation';
5
4
  import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
6
5
 
7
6
  import {
8
7
  beginTwoFactorChallenge, clearOAuthAttempt, isOAuthProviderId, type OAuthFailureCode, useOAuthLogin
9
8
  } from '@djangocfg/api/auth';
10
- import { useAppT } from '@djangocfg/i18n';
11
- import { Card, CardDescription, CardHeader, CardTitle } from '@djangocfg/ui-core/components';
12
9
 
13
- import { useAuthFormContext } from '../../context';
14
- import { readOAuthCallback, stripOAuthCallbackParams } from './oauthCallbackParams';
15
- import { useOAuthErrorText } from './oauthErrors';
16
- import { describeOAuthFailure } from './oauthNoticeCopy';
10
+ import { useAuthFormContext } from '../../../context';
11
+ import { describeOAuthFailure } from '../errors/errorCopy';
12
+ import { useOAuthErrorText } from '../errors/useOAuthErrorText';
13
+ import { readOAuthCallback, stripOAuthCallbackParams } from './callbackParams';
14
+ import { OAuthCallbackOverlay } from './OAuthCallbackOverlay';
17
15
 
18
16
  export interface OAuthCallbackProps {
19
17
  /** `user` is the provider callback's user payload, untyped upstream — narrow it before reading fields. */
@@ -43,7 +41,6 @@ type CallbackStatus = 'idle' | 'processing' | 'challenge';
43
41
  function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProps) {
44
42
  const searchParams = useSearchParams();
45
43
  const { setStep, setOAuthNotice } = useAuthFormContext();
46
- const t = useAppT();
47
44
  const errorText = useOAuthErrorText();
48
45
 
49
46
  const [status, setStatus] = useState<CallbackStatus>('idle');
@@ -58,7 +55,7 @@ function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProp
58
55
  // Cancel is not an error: the visitor is simply back at the form.
59
56
  if (describeOAuthFailure(code)) setOAuthNotice({ provider: providerId, code });
60
57
  setStatus('idle');
61
- onError?.(errorText(code, message));
58
+ onError?.(errorText(code, message, providerId));
62
59
  },
63
60
  [errorText, setOAuthNotice, onError],
64
61
  );
@@ -107,30 +104,7 @@ function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProp
107
104
 
108
105
  // Success is the layout's success screen (setStep('success')), a failure is
109
106
  // the notice on the form; this overlay covers only the wait for the exchange.
110
- return (
111
- <div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
112
- <Card className="w-full max-w-md mx-4 shadow-lg">
113
- <CardHeader className="text-center">
114
- <div role="status" aria-live="polite">
115
- <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
116
- <Loader2 className="w-6 h-6 text-primary animate-spin" aria-hidden="true" />
117
- </div>
118
- <CardTitle>{t('layouts.auth.oauth.signingIn')}</CardTitle>
119
- <CardDescription>
120
- {t('layouts.auth.oauth.signingInBody', { provider: providerLabel(provider) })}
121
- </CardDescription>
122
- </div>
123
- </CardHeader>
124
- </Card>
125
- </div>
126
- );
107
+ return <OAuthCallbackOverlay provider={provider} />;
127
108
  }
128
109
 
129
- /** Brand names are not translated; an id we do not know is shown as it came. */
130
- const providerLabel = (id: string | null): string => {
131
- if (id === 'github') return 'GitHub';
132
- if (id === 'google') return 'Google';
133
- return id ?? '';
134
- };
135
-
136
110
  export const OAuthCallback = memo(OAuthCallbackRaw);
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { Loader2 } from 'lucide-react';
4
+ import React, { memo } from 'react';
5
+
6
+ import { useAppT } from '@djangocfg/i18n';
7
+ import { Card, CardDescription, CardHeader, CardTitle } from '@djangocfg/ui-core/components';
8
+
9
+ import { providerName } from '../providers/registry';
10
+
11
+ export interface OAuthCallbackOverlayProps {
12
+ /** Provider id as it came back in the URL. */
13
+ provider: string | null;
14
+ }
15
+
16
+ /**
17
+ * Covers the page while the provider's code is exchanged for a session.
18
+ * A polite live region: the wait is announced once, then the page changes.
19
+ */
20
+ function OAuthCallbackOverlayRaw({ provider }: OAuthCallbackOverlayProps) {
21
+ const t = useAppT();
22
+ return (
23
+ <div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
24
+ <Card className="w-full max-w-md mx-4 shadow-lg">
25
+ <CardHeader className="text-center">
26
+ <div role="status" aria-live="polite">
27
+ <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
28
+ <Loader2 className="w-6 h-6 text-primary animate-spin" aria-hidden="true" />
29
+ </div>
30
+ <CardTitle>{t('layouts.auth.oauth.signingIn')}</CardTitle>
31
+ <CardDescription>
32
+ {t('layouts.auth.oauth.signingInBody', { provider: providerName(provider ?? '') })}
33
+ </CardDescription>
34
+ </div>
35
+ </CardHeader>
36
+ </Card>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export const OAuthCallbackOverlay = memo(OAuthCallbackOverlayRaw);
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { readOAuthCallback, stripOAuthCallbackParams } from './oauthCallbackParams';
3
+ import { readOAuthCallback, stripOAuthCallbackParams } from './callbackParams';
4
4
 
5
5
  const isProvider = (value: string): value is 'github' | 'google' => value === 'github' || value === 'google';
6
6
  const read = (query: string) => readOAuthCallback(new URLSearchParams(query), isProvider);
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { describeOAuthFailure, oauthErrorKey } from './errorCopy';
4
+
5
+ describe('describeOAuthFailure', () => {
6
+ it('says nothing when the visitor cancelled', () => {
7
+ expect(describeOAuthFailure('cancelled')).toBeNull();
8
+ });
9
+
10
+ it('points a staff account at the email code, with a heading', () => {
11
+ expect(describeOAuthFailure('link_not_allowed')).toEqual({
12
+ titleKey: 'layouts.auth.oauth.notice.linkNotAllowedTitle',
13
+ bodyKey: 'layouts.auth.oauth.error.link_not_allowed',
14
+ action: 'email',
15
+ developerHintKey: undefined,
16
+ });
17
+ });
18
+
19
+ it('offers a retry where trying again can help', () => {
20
+ for (const code of ['invalid_state', 'oauth_exchange_failed', 'provider_unavailable', 'server_error'] as const) {
21
+ expect(describeOAuthFailure(code)?.action).toBe('retry');
22
+ }
23
+ });
24
+
25
+ it('offers nothing the visitor cannot act on', () => {
26
+ expect(describeOAuthFailure('account_inactive')?.action).toBe('none');
27
+ expect(describeOAuthFailure('rate_limited')?.action).toBe('none');
28
+ });
29
+
30
+ it('treats a missing code as the generic failure', () => {
31
+ expect(describeOAuthFailure(undefined)).toEqual({
32
+ titleKey: undefined,
33
+ bodyKey: 'layouts.auth.oauth.error.generic',
34
+ action: 'retry',
35
+ developerHintKey: undefined,
36
+ });
37
+ });
38
+
39
+ it('says the site is not set up, with a developer hint, when the origin is refused', () => {
40
+ expect(describeOAuthFailure('origin_not_allowed')).toEqual({
41
+ titleKey: undefined,
42
+ bodyKey: 'layouts.auth.oauth.error.not_configured',
43
+ action: 'email',
44
+ developerHintKey: 'layouts.auth.oauth.notice.originNotAllowedHint',
45
+ });
46
+ });
47
+
48
+ it('gives the other configuration faults the same sentence without the hint', () => {
49
+ for (const code of ['oauth_disabled', 'github_oauth_disabled', 'missing_redirect_uri'] as const) {
50
+ const copy = describeOAuthFailure(code);
51
+ expect(copy?.bodyKey).toBe('layouts.auth.oauth.error.not_configured');
52
+ expect(copy?.developerHintKey).toBeUndefined();
53
+ }
54
+ });
55
+ });
56
+
57
+ describe('oauthErrorKey', () => {
58
+ it('maps a code to its sentence and an unknown cause to the generic one', () => {
59
+ expect(oauthErrorKey('rate_limited')).toBe('layouts.auth.oauth.error.rate_limited');
60
+ expect(oauthErrorKey('server_error')).toBe('layouts.auth.oauth.error.generic');
61
+ expect(oauthErrorKey(undefined)).toBe('layouts.auth.oauth.error.generic');
62
+ });
63
+ });