@djangocfg/layouts 2.1.599 → 2.1.601
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/configurator/auth/defaults.ts +2 -0
- package/src/configurator/auth/schema.ts +4 -0
- package/src/configurator/auth/types.ts +1 -0
- package/src/layouts/AuthLayout/AuthLayout.tsx +9 -13
- package/src/layouts/AuthLayout/README.md +17 -2
- package/src/layouts/AuthLayout/components/oauth/GithubIcon.tsx +21 -0
- package/src/layouts/AuthLayout/components/oauth/GoogleIcon.tsx +29 -0
- package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +82 -0
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +101 -111
- package/src/layouts/AuthLayout/components/oauth/index.ts +2 -0
- package/src/layouts/AuthLayout/components/oauth/oauthCallbackParams.test.ts +76 -0
- package/src/layouts/AuthLayout/components/oauth/oauthCallbackParams.ts +70 -0
- package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +64 -0
- package/src/layouts/AuthLayout/components/oauth/oauthProviders.test.ts +27 -0
- package/src/layouts/AuthLayout/components/oauth/oauthProviders.ts +17 -0
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +4 -2
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +30 -28
- package/src/layouts/AuthLayout/context.tsx +15 -4
- package/src/layouts/AuthLayout/styles/auth.css +87 -11
- package/src/layouts/AuthLayout/types.ts +17 -3
- package/src/testing/MockAuthFormProvider.tsx +48 -7
- package/src/testing/index.ts +19 -0
- package/src/testing/mockOAuthApi.ts +230 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.601",
|
|
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.601",
|
|
95
|
+
"@djangocfg/api": "^2.1.601",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.601",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.601",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.601",
|
|
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.
|
|
129
|
-
"@djangocfg/api": "^2.1.
|
|
130
|
-
"@djangocfg/devtools": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
133
|
-
"@djangocfg/ui-core": "^2.1.
|
|
128
|
+
"@djangocfg/analytics": "^2.1.601",
|
|
129
|
+
"@djangocfg/api": "^2.1.601",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.601",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.601",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.601",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.601",
|
|
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
|
};
|
|
@@ -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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
{/*
|
|
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
|
-
| `
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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.
|
|
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
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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>
|
|
131
|
+
<CardTitle>{t('layouts.auth.oauth.signingIn')}</CardTitle>
|
|
147
132
|
<CardDescription>
|
|
148
|
-
|
|
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>
|
|
159
|
-
<CardDescription className="text-destructive">
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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);
|