@djangocfg/layouts 2.1.599 → 2.1.600

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.599",
3
+ "version": "2.1.600",
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.599",
95
- "@djangocfg/api": "^2.1.599",
96
- "@djangocfg/devtools": "^2.1.599",
97
- "@djangocfg/i18n": "^2.1.599",
98
- "@djangocfg/ui-core": "^2.1.599",
94
+ "@djangocfg/analytics": "^2.1.600",
95
+ "@djangocfg/api": "^2.1.600",
96
+ "@djangocfg/devtools": "^2.1.600",
97
+ "@djangocfg/i18n": "^2.1.600",
98
+ "@djangocfg/ui-core": "^2.1.600",
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.599",
129
- "@djangocfg/api": "^2.1.599",
130
- "@djangocfg/devtools": "^2.1.599",
131
- "@djangocfg/i18n": "^2.1.599",
132
- "@djangocfg/typescript-config": "^2.1.599",
133
- "@djangocfg/ui-core": "^2.1.599",
128
+ "@djangocfg/analytics": "^2.1.600",
129
+ "@djangocfg/api": "^2.1.600",
130
+ "@djangocfg/devtools": "^2.1.600",
131
+ "@djangocfg/i18n": "^2.1.600",
132
+ "@djangocfg/typescript-config": "^2.1.600",
133
+ "@djangocfg/ui-core": "^2.1.600",
134
134
  "@storybook/react-vite": "^10.5.0",
135
135
  "@types/node": "^25.9.5",
136
136
  "@types/react": "19.2.15",
@@ -39,6 +39,7 @@ export const defaultCenteredConfig: AuthCenteredConfig = {
39
39
  showLogo: true,
40
40
  showTerms: true,
41
41
  showGithub: true,
42
+ showGoogle: true,
42
43
  showSupport: true,
43
44
  },
44
45
  };
@@ -60,6 +61,7 @@ export const defaultSplitConfig: AuthSplitConfig = {
60
61
  showLogo: true,
61
62
  showTerms: true,
62
63
  showGithub: true,
64
+ showGoogle: true,
63
65
  showSupport: true,
64
66
  },
65
67
  };
@@ -64,6 +64,7 @@ export const authCenteredSchema: CustomJsonSchema7 = {
64
64
  showLogo: { type: 'boolean', title: 'Logo on success screen' },
65
65
  showTerms: { type: 'boolean', title: 'Terms checkbox' },
66
66
  showGithub: { type: 'boolean', title: 'GitHub OAuth button' },
67
+ showGoogle: { type: 'boolean', title: 'Google OAuth button' },
67
68
  showSupport: { type: 'boolean', title: 'Support link in footer' },
68
69
  },
69
70
  },
@@ -91,6 +92,7 @@ export const authCenteredUiSchema: CustomJsonUiSchema7 = {
91
92
  showLogo: { 'ui:widget': 'switch' },
92
93
  showTerms: { 'ui:widget': 'switch' },
93
94
  showGithub: { 'ui:widget': 'switch' },
95
+ showGoogle: { 'ui:widget': 'switch' },
94
96
  showSupport: { 'ui:widget': 'switch' },
95
97
  },
96
98
  };
@@ -161,6 +163,7 @@ export const authSplitSchema: CustomJsonSchema7 = {
161
163
  showLogo: { type: 'boolean', title: 'Logo on success screen' },
162
164
  showTerms: { type: 'boolean', title: 'Terms checkbox' },
163
165
  showGithub: { type: 'boolean', title: 'GitHub OAuth button' },
166
+ showGoogle: { type: 'boolean', title: 'Google OAuth button' },
164
167
  showSupport: { type: 'boolean', title: 'Support link in footer' },
165
168
  },
166
169
  },
@@ -200,6 +203,7 @@ export const authSplitUiSchema: CustomJsonUiSchema7 = {
200
203
  showLogo: { 'ui:widget': 'switch' },
201
204
  showTerms: { 'ui:widget': 'switch' },
202
205
  showGithub: { 'ui:widget': 'switch' },
206
+ showGoogle: { 'ui:widget': 'switch' },
203
207
  showSupport: { 'ui:widget': 'switch' },
204
208
  },
205
209
  };
@@ -23,6 +23,7 @@ export interface AuthFormConfig {
23
23
  showLogo: boolean;
24
24
  showTerms: boolean;
25
25
  showGithub: boolean;
26
+ showGoogle: boolean;
26
27
  showSupport: boolean;
27
28
  }
28
29
 
@@ -7,7 +7,7 @@
7
7
  * - `centered` — Apple-style frameless, centered, glow background
8
8
  * - `split` — Two-column frosted card on desktop, single-column on mobile
9
9
  *
10
- * Supports: email/phone OTP, OAuth (GitHub). A 2FA challenge opens as a dialog
10
+ * Supports: email/phone OTP and OAuth (Google, GitHub: the buttons come from the server). A 2FA challenge opens as a dialog
11
11
  * over the page (`TwoFactorChallengeDialog`), not as a step of this layout.
12
12
  */
13
13
 
@@ -57,7 +57,6 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
57
57
  background,
58
58
  sidebar,
59
59
  logo,
60
- enableGithubAuth,
61
60
  // No default here — an unset prop falls through to the saved back-url /
62
61
  // resolved routes.defaultCallback chain at navigation time (AuthSuccess).
63
62
  redirectUrl,
@@ -97,18 +96,15 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
97
96
  // the layout so it does not depend on `logo` or `sidebar`, which are optional.
98
97
  const topBar = useMemo(() => <AuthTopBar brandPanel={hasBrandPanel} />, [hasBrandPanel]);
99
98
 
100
- const oauthCallback = useMemo(() => {
101
- if (!enableGithubAuth) return null;
102
- return (
99
+ // Always mounted: it renders nothing unless the URL carries `?provider=<id>`.
100
+ const oauthCallback = useMemo(
101
+ () => (
103
102
  <Suspense fallback={null}>
104
- <OAuthCallback
105
- redirectUrl={redirectUrl}
106
- onSuccess={onOAuthSuccess ? (user, isNewUser) => onOAuthSuccess(user, isNewUser, 'github') : undefined}
107
- onError={onError}
108
- />
103
+ <OAuthCallback redirectUrl={redirectUrl} onSuccess={onOAuthSuccess} onError={onError} />
109
104
  </Suspense>
110
- );
111
- }, [enableGithubAuth, redirectUrl, onOAuthSuccess, onError]);
105
+ ),
106
+ [redirectUrl, onOAuthSuccess, onError],
107
+ );
112
108
 
113
109
  return (
114
110
  <Suspense>
@@ -127,7 +123,7 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
127
123
  topBar={topBar}
128
124
  className={className}
129
125
  >
130
- {/* Handle OAuth callback when GitHub auth is enabled */}
126
+ {/* Finishes a provider sign-in when the URL carries one */}
131
127
  {oauthCallback}
132
128
 
133
129
  <AuthHeaderSlot>{children}</AuthHeaderSlot>
@@ -134,7 +134,6 @@ import { AuthLayout } from '@djangocfg/layouts';
134
134
  <AuthLayout
135
135
  sourceUrl="https://myapp.com"
136
136
  redirectUrl="/private"
137
- enableGithubAuth
138
137
  termsUrl="/terms"
139
138
  privacyUrl="/privacy"
140
139
  >
@@ -184,7 +183,8 @@ import { AuthLayout } from '@djangocfg/layouts';
184
183
  | `sidebar` | `ReactNode` | — | Media/side column content. On `fullsplit`, omitting it with `logo` set yields the default `AuthBrandPanel` |
185
184
  | `sourceUrl` | `string` | — | App URL for analytics/tracking |
186
185
  | `redirectUrl` | `string` | `/private` | Where to redirect after auth |
187
- | `enableGithubAuth` | `boolean` | `false` | Show GitHub OAuth button |
186
+ | `oauthProviders` | `('github' \| 'google')[]` | server list | Buttons come from the server (`GET /oauth/providers/`); this only narrows or orders them |
187
+ | `enableGithubAuth` | `boolean` | — | Deprecated, no effect |
188
188
  | `enablePhoneAuth` | `boolean` | `false` | Allow phone number input |
189
189
  | `logo` | `ReactNode` | — | Brand node. Placed by variant (see [Where the brand goes](#where-the-brand-goes)); the slot caps its height and keeps its aspect ratio |
190
190
  | `termsUrl` | `string` | — | Terms link — rendered in the passive consent line (opens in a new tab) |
@@ -263,6 +263,21 @@ only component that could draw it also navigated away 1.5s later, so nobody
263
263
  could sit and look at it — which is how it kept showing the brand instead of a
264
264
  confirmation. See `Success` in `AuthFlow.stories.tsx`.
265
265
 
266
+ ## Provider sign-in stories
267
+
268
+ `AuthFlow.stories.tsx` runs the real hooks against a faked network. One place
269
+ fakes it: `oauthApiMock` (`@djangocfg/layouts/testing`, `testing/mockOAuthApi.ts`),
270
+ used as a story `beforeEach`. It answers `GET /oauth/providers/`, the two
271
+ `authorize` and `callback` POSTs (any of the 16 error codes, with its HTTP
272
+ status) at the `fetch` level, refuses every other request, and holds back
273
+ full-page navigations (the redirect to the provider, the redirect after
274
+ sign-in) so the iframe stays put; `getOAuthApiMock()` exposes the recorded
275
+ calls and navigations for `play` assertions. Pair it with
276
+ `MockAuthFormProvider oauthFromServer` for the button list, or run the whole
277
+ `AuthLayout` for a callback round trip. The callback stories put the provider's
278
+ parameters in the address bar and the stored state in `sessionStorage`
279
+ (`seedOAuthBrowser`), as a real return leaves them.
280
+
266
281
  ## Errors
267
282
 
268
283
  The form never shows a raw transport string. `@djangocfg/api`'s
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * GitHub's mark in one colour: `currentColor`, so the button's `--foreground`
5
+ * token makes it dark on light themes and light on dark ones.
6
+ */
7
+ export function GithubIcon() {
8
+ return (
9
+ <svg
10
+ className="auth-oauth-icon"
11
+ width="20"
12
+ height="20"
13
+ viewBox="0 -3.1 2490.3 2493"
14
+ fill="currentColor"
15
+ aria-hidden="true"
16
+ focusable="false"
17
+ >
18
+ <path d="m1245.2 1.6c-687.6 0-1245.2 557.4-1245.2 1245.1 0 550.2 356.8 1016.9 851.5 1181.5 62.2 11.5 85.1-27 85.1-59.9 0-29.7-1.2-127.8-1.7-231.8-346.4 75.3-419.5-146.9-419.5-146.9-56.6-143.9-138.3-182.2-138.3-182.2-113-77.3 8.5-75.7 8.5-75.7 125 8.8 190.9 128.3 190.9 128.3 111.1 190.4 291.3 135.3 362.3 103.5 11.2-80.5 43.4-135.4 79.1-166.5-276.6-31.5-567.3-138.3-567.3-615.4 0-135.9 48.6-247 128.3-334.2-12.9-31.3-55.5-157.9 12.1-329.4 0 0 104.6-33.5 342.5 127.6 99.3-27.6 205.8-41.4 311.7-41.9 105.8.5 212.4 14.3 311.9 41.9 237.7-161.1 342.1-127.6 342.1-127.6 67.8 171.5 25.1 298.2 12.2 329.5 79.8 87.2 128.1 198.3 128.1 334.2 0 478.2-291.3 583.6-568.6 614.4 44.7 38.6 84.5 114.4 84.5 230.6 0 166.6-1.4 300.7-1.4 341.7 0 33.1 22.4 72 85.5 59.7 494.5-164.8 850.8-631.4 850.8-1181.4 0-687.7-557.5-1245.1-1245.1-1245.1" />
19
+ </svg>
20
+ );
21
+ }
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Google's multi-colour "G". Fixed colours on purpose: Google's branding rules
5
+ * forbid recolouring and monochrome variants, and require the mark on the
6
+ * button boundary with the text beside it.
7
+ */
8
+ export function GoogleIcon() {
9
+ return (
10
+ <svg className="auth-oauth-icon" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
11
+ <path
12
+ fill="#EA4335"
13
+ d="M5.27 9.76A7.08 7.08 0 0 1 16.42 6.5L19.9 3A11.97 11.97 0 0 0 1.24 6.65l4.03 3.11Z"
14
+ />
15
+ <path
16
+ fill="#34A853"
17
+ d="M16.04 18.01A7.4 7.4 0 0 1 12 19.1a7.08 7.08 0 0 1-6.72-4.82l-4.04 3.06A11.96 11.96 0 0 0 12 24a11.4 11.4 0 0 0 7.83-3l-3.79-2.99Z"
18
+ />
19
+ <path
20
+ fill="#4A90E2"
21
+ d="M19.83 21c2.2-2.05 3.62-5.1 3.62-9 0-.7-.1-1.47-.27-2.18H12v4.63h6.44a5.4 5.4 0 0 1-2.4 3.56l3.8 2.99Z"
22
+ />
23
+ <path
24
+ fill="#FBBC05"
25
+ d="M5.28 14.27a7.12 7.12 0 0 1-.01-4.5L1.24 6.64A11.93 11.93 0 0 0 0 12c0 1.92.44 3.73 1.24 5.33l4.04-3.06Z"
26
+ />
27
+ </svg>
28
+ );
29
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import React, { memo, useCallback, useMemo } from 'react';
4
+
5
+ import { type OAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
6
+ import { useAppT } from '@djangocfg/i18n';
7
+
8
+ import { AuthButton, type AuthButtonProps } from '../shared';
9
+ import { GithubIcon } from './GithubIcon';
10
+ import { GoogleIcon } from './GoogleIcon';
11
+ import { useOAuthErrorText } from './oauthErrors';
12
+
13
+ interface OAuthProviderEntry {
14
+ Icon: React.FC;
15
+ variant: NonNullable<AuthButtonProps['variant']>;
16
+ labelKey: 'layouts.auth.identifier.oauth.github' | 'layouts.auth.identifier.oauth.google';
17
+ }
18
+
19
+ /** One entry per provider the client can run; keyed by the API package's id type, so a new provider fails typecheck until it has a button. */
20
+ export const OAUTH_PROVIDER_UI: Record<OAuthProviderId, OAuthProviderEntry> = {
21
+ github: { Icon: GithubIcon, variant: 'secondary', labelKey: 'layouts.auth.identifier.oauth.github' },
22
+ google: { Icon: GoogleIcon, variant: 'google', labelKey: 'layouts.auth.identifier.oauth.google' },
23
+ };
24
+
25
+ export interface OAuthButtonsProps {
26
+ providers: readonly OAuthProviderId[];
27
+ sourceUrl?: string;
28
+ /** Disables every button, e.g. while the email form is submitting. */
29
+ disabled?: boolean;
30
+ onError?: (message: string) => void;
31
+ }
32
+
33
+ /**
34
+ * The provider buttons: same size and weight each, progress only on the one
35
+ * clicked, the rest disabled while its flow is in flight.
36
+ */
37
+ function OAuthButtonsRaw({ providers, sourceUrl, disabled, onError }: OAuthButtonsProps) {
38
+ const t = useAppT();
39
+ const errorText = useOAuthErrorText();
40
+
41
+ const { start, loadingProvider, isLoading } = useOAuthLogin({
42
+ sourceUrl,
43
+ onError: (message, code) => onError?.(errorText(code, message)),
44
+ });
45
+
46
+ const labels = useMemo(
47
+ () => Object.fromEntries(providers.map((id) => [id, t(OAUTH_PROVIDER_UI[id].labelKey)])) as Record<string, string>,
48
+ [providers, t],
49
+ );
50
+
51
+ const handleClick = useCallback(
52
+ (event: React.MouseEvent<HTMLButtonElement>) => {
53
+ const id = event.currentTarget.dataset.provider as OAuthProviderId;
54
+ void start(id);
55
+ },
56
+ [start],
57
+ );
58
+
59
+ return (
60
+ <div className="auth-oauth" role="group">
61
+ {providers.map((id) => {
62
+ const { Icon, variant } = OAUTH_PROVIDER_UI[id];
63
+ return (
64
+ <AuthButton
65
+ key={id}
66
+ type="button"
67
+ variant={variant}
68
+ data-provider={id}
69
+ loading={loadingProvider === id}
70
+ disabled={disabled || (isLoading && loadingProvider !== id)}
71
+ onClick={handleClick}
72
+ >
73
+ <Icon />
74
+ {labels[id]}
75
+ </AuthButton>
76
+ );
77
+ })}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ export const OAuthButtons = memo(OAuthButtonsRaw);
@@ -2,14 +2,20 @@
2
2
 
3
3
  import { AlertCircle, Loader2 } from 'lucide-react';
4
4
  import { useSearchParams } from 'next/navigation';
5
- import React, { memo, useEffect, useState } from 'react';
5
+ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
6
6
 
7
- import { beginTwoFactorChallenge, useAuth, useGithubAuth } from '@djangocfg/api/auth';
7
+ import {
8
+ beginTwoFactorChallenge, clearOAuthAttempt, isOAuthProviderId, type OAuthFailureCode, useAuth,
9
+ useOAuthLogin
10
+ } from '@djangocfg/api/auth';
11
+ import { useAppT } from '@djangocfg/i18n';
8
12
  import {
9
13
  Card, CardContent, CardDescription, CardHeader, CardTitle
10
14
  } from '@djangocfg/ui-core/components';
11
15
 
12
16
  import { useAuthFormContext } from '../../context';
17
+ import { readOAuthCallback, stripOAuthCallbackParams } from './oauthCallbackParams';
18
+ import { useOAuthErrorText } from './oauthErrors';
13
19
 
14
20
  export interface OAuthCallbackProps {
15
21
  /** `user` is the provider callback's user payload, untyped upstream — narrow it before reading fields. */
@@ -18,158 +24,135 @@ export interface OAuthCallbackProps {
18
24
  redirectUrl?: string;
19
25
  }
20
26
 
21
- type CallbackStatus = 'processing' | 'error' | 'challenge';
27
+ type CallbackStatus = 'idle' | 'processing' | 'error' | 'challenge';
28
+
29
+ interface Failure {
30
+ message: string;
31
+ /** Google refused to open on its own page, which we never see; point at the likely cause. */
32
+ browserHint: boolean;
33
+ }
22
34
 
23
35
  /**
24
- * OAuth Callback Handler Component
25
- *
26
- * Processes OAuth callback from providers (GitHub, etc.).
27
- * Reads code, state, and provider from URL params and exchanges for tokens.
28
- *
29
- * Usage:
30
- * Place this component on your /auth page to handle OAuth callbacks.
36
+ * Finishes a provider sign-in on the page the provider returned to.
31
37
  *
32
- * @example
33
- * ```tsx
34
- * // app/auth/page.tsx
35
- * import { OAuthCallback, AuthLayout } from '@djangocfg/layouts';
38
+ * Mounted by `AuthLayout`; it renders nothing unless the URL carries
39
+ * `?provider=<id>`. It reads the URL once, strips the single-use parameters
40
+ * from the address bar, then either exchanges the code (the stored `state`
41
+ * must match, see `useOAuthLogin`) or shows why there is no code (Cancel,
42
+ * a provider error, an unknown provider).
36
43
  *
37
- * export default function AuthPage() {
38
- * return (
39
- * <>
40
- * <OAuthCallback
41
- * onSuccess={(user) => console.log('OAuth success:', user)}
42
- * onError={(error) => console.error('OAuth error:', error)}
43
- * />
44
- * <AuthLayout enableGithubAuth>
45
- * {/* Your auth content *\/}
46
- * </AuthLayout>
47
- * </>
48
- * );
49
- * }
50
- * ```
44
+ * Success hands over to the layout's success screen, which consumes the saved
45
+ * return URL; this component never navigates itself.
51
46
  *
52
47
  * Memoised: re-renders only when `onSuccess`, `onError`, or `redirectUrl`
53
- * change. `onSuccess` and `onError` are compared by reference — callers
54
- * should stabilise them with useCallback.
48
+ * change. Callbacks are compared by reference; stabilise them with useCallback.
55
49
  */
56
- function OAuthCallbackRaw({
57
- onSuccess,
58
- onError,
59
- redirectUrl,
60
- }: OAuthCallbackProps) {
50
+ function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProps) {
61
51
  const searchParams = useSearchParams();
62
52
  const { routes } = useAuth();
63
53
  const { setStep } = useAuthFormContext();
64
- const [status, setStatus] = useState<CallbackStatus | null>(null);
65
- const [errorMessage, setErrorMessage] = useState<string | null>(null);
66
-
67
- const provider = searchParams.get('provider');
68
- const code = searchParams.get('code');
69
- const state = searchParams.get('state');
70
- const error = searchParams.get('error');
71
- const errorDescription = searchParams.get('error_description');
72
-
73
- const {
74
- handleGithubCallback,
75
- isLoading: _isLoading,
76
- error: githubError,
77
- } = useGithubAuth({
78
- onSuccess: (user, isNewUser) => {
79
- // Show success screen with logo instead of redirecting
80
- setStep('success');
81
- onSuccess?.(user, isNewUser, 'github');
82
- },
83
- onError: (err) => {
54
+ const t = useAppT();
55
+ const errorText = useOAuthErrorText();
56
+
57
+ const [status, setStatus] = useState<CallbackStatus>('idle');
58
+ const [provider, setProvider] = useState<string | null>(null);
59
+ const [failure, setFailure] = useState<Failure | null>(null);
60
+
61
+ // One attempt per page load: strict mode runs effects twice, and the
62
+ // stripped URL re-renders this component with no provider left to read.
63
+ const handledRef = useRef(false);
64
+
65
+ const fail = useCallback(
66
+ (providerId: string, code: OAuthFailureCode | undefined, message?: string) => {
67
+ const text = errorText(code, message);
68
+ setFailure({
69
+ message: text,
70
+ browserHint: providerId === 'google' && (code === undefined || code === 'server_error'),
71
+ });
84
72
  setStatus('error');
85
- setErrorMessage(err);
86
- onError?.(err);
73
+ onError?.(text);
74
+ },
75
+ [errorText, onError],
76
+ );
77
+
78
+ const failRef = useRef(fail);
79
+ failRef.current = fail;
80
+ const providerRef = useRef<string | null>(null);
81
+
82
+ const { complete } = useOAuthLogin({
83
+ redirectUrl,
84
+ skipRedirect: true, // The success screen navigates.
85
+ onSuccess: (user, isNewUser, providerId) => {
86
+ setStep('success');
87
+ onSuccess?.(user, isNewUser, providerId);
87
88
  },
89
+ onError: (message, code) => failRef.current(providerRef.current ?? '', code, message),
88
90
  onRequires2FA: (sessionId) => {
89
91
  // The dialog needs the page; this overlay would cover it.
90
92
  setStatus('challenge');
91
93
  beginTwoFactorChallenge({ sessionId });
92
94
  },
93
- redirectUrl,
94
- skipRedirect: true, // We handle navigation via success screen
95
95
  });
96
96
 
97
- // Process OAuth callback on mount
98
97
  useEffect(() => {
99
- // Check if this is an OAuth callback
100
- if (!provider || !code || !state) {
101
- // Not an OAuth callback, don't show anything
98
+ if (handledRef.current) return;
99
+ const intent = readOAuthCallback(searchParams, isOAuthProviderId);
100
+ if (intent.kind === 'none') return;
101
+ handledRef.current = true;
102
+
103
+ window.history.replaceState(window.history.state, '', stripOAuthCallbackParams(window.location.href));
104
+ providerRef.current = intent.provider;
105
+ setProvider(intent.provider);
106
+
107
+ if (intent.kind === 'failure') {
108
+ // The attempt is over: no exchange will spend the stored state.
109
+ clearOAuthAttempt();
110
+ fail(intent.provider, intent.code, intent.description);
102
111
  return;
103
112
  }
104
113
 
105
- // Check for error from provider
106
- if (error) {
107
- setStatus('error');
108
- setErrorMessage(errorDescription || error);
109
- onError?.(errorDescription || error);
110
- return;
111
- }
114
+ setStatus('processing');
115
+ void complete(intent.provider, intent.code, intent.state);
116
+ }, [searchParams, complete, fail]);
117
+
118
+ if (status === 'idle' || status === 'challenge') return null;
112
119
 
113
- // Process based on provider
114
- const processCallback = async () => {
115
- setStatus('processing');
116
-
117
- if (provider === 'github') {
118
- await handleGithubCallback(code, state);
119
- } else {
120
- setStatus('error');
121
- setErrorMessage(`Unsupported OAuth provider: ${provider}`);
122
- onError?.(`Unsupported OAuth provider: ${provider}`);
123
- }
124
- };
125
-
126
- processCallback();
127
- // eslint-disable-next-line react-hooks/exhaustive-deps
128
- }, [provider, code, state, error]);
129
-
130
- // Don't render if not an OAuth callback
131
- if (!provider || (!code && !error) || status === 'challenge') {
132
- return null;
133
- }
134
-
135
- // Only show UI for processing or error states
136
- // Success state is handled by AuthSuccess component via setStep('success')
120
+ // Success is the layout's success screen (setStep('success')); this overlay
121
+ // only covers waiting and failing.
137
122
  return (
138
123
  <div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
139
124
  <Card className="w-full max-w-md mx-4 shadow-lg">
140
125
  <CardHeader className="text-center">
141
126
  {status === 'processing' && (
142
- <>
127
+ <div role="status" aria-live="polite">
143
128
  <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
144
- <Loader2 className="w-6 h-6 text-primary animate-spin" />
129
+ <Loader2 className="w-6 h-6 text-primary animate-spin" aria-hidden="true" />
145
130
  </div>
146
- <CardTitle>Signing you in...</CardTitle>
131
+ <CardTitle>{t('layouts.auth.oauth.signingIn')}</CardTitle>
147
132
  <CardDescription>
148
- Please wait while we complete your {provider} authentication.
133
+ {t('layouts.auth.oauth.signingInBody', { provider: providerLabel(provider) })}
149
134
  </CardDescription>
150
- </>
135
+ </div>
151
136
  )}
152
137
 
153
138
  {status === 'error' && (
154
- <>
139
+ <div role="alert">
155
140
  <div className="mx-auto w-12 h-12 bg-destructive/10 rounded-full flex items-center justify-center mb-4">
156
- <AlertCircle className="w-6 h-6 text-destructive" />
141
+ <AlertCircle className="w-6 h-6 text-destructive" aria-hidden="true" />
157
142
  </div>
158
- <CardTitle>Authentication Failed</CardTitle>
159
- <CardDescription className="text-destructive">
160
- {errorMessage || githubError || 'An error occurred during authentication.'}
161
- </CardDescription>
162
- </>
143
+ <CardTitle>{t('layouts.auth.oauth.failedTitle')}</CardTitle>
144
+ <CardDescription className="text-destructive">{failure?.message}</CardDescription>
145
+ {failure?.browserHint && (
146
+ <CardDescription className="mt-2">{t('layouts.auth.oauth.browserHint')}</CardDescription>
147
+ )}
148
+ </div>
163
149
  )}
164
150
  </CardHeader>
165
151
 
166
152
  {status === 'error' && (
167
153
  <CardContent className="text-center">
168
- <a
169
- href={routes.auth}
170
- className="text-primary hover:underline text-sm"
171
- >
172
- Try again
154
+ <a href={routes.auth} className="text-primary hover:underline text-sm" autoFocus>
155
+ {t('layouts.auth.oauth.tryAgain')}
173
156
  </a>
174
157
  </CardContent>
175
158
  )}
@@ -178,4 +161,11 @@ function OAuthCallbackRaw({
178
161
  );
179
162
  }
180
163
 
164
+ /** Brand names are not translated; an id we do not know is shown as it came. */
165
+ const providerLabel = (id: string | null): string => {
166
+ if (id === 'github') return 'GitHub';
167
+ if (id === 'google') return 'Google';
168
+ return id ?? '';
169
+ };
170
+
181
171
  export const OAuthCallback = memo(OAuthCallbackRaw);
@@ -1 +1,3 @@
1
1
  export { OAuthCallback, type OAuthCallbackProps } from './OAuthCallback';
2
+ export { OAuthButtons, type OAuthButtonsProps } from './OAuthButtons';
3
+ export { resolveOAuthProviders } from './oauthProviders';