@djangocfg/layouts 2.1.601 → 2.1.603
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/components/errors/ErrorsTracker/components/ErrorButtons.tsx +2 -2
- package/src/layouts/AuthLayout/README.md +22 -0
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +25 -60
- package/src/layouts/AuthLayout/components/oauth/OAuthNotice.tsx +94 -0
- package/src/layouts/AuthLayout/components/oauth/index.ts +1 -0
- package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +1 -42
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +36 -0
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +86 -0
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +15 -2
- package/src/layouts/AuthLayout/context.tsx +7 -2
- package/src/layouts/AuthLayout/index.ts +1 -0
- package/src/layouts/AuthLayout/types.ts +10 -1
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +2 -0
- package/src/layouts/PrivateLayout/README.md +5 -1
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +3 -1
- package/src/layouts/PrivateLayout/components/SidebarNav.test.tsx +152 -0
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +85 -45
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +54 -13
- package/src/layouts/PrivateLayout/context.tsx +5 -3
- package/src/layouts/PrivateLayout/index.ts +4 -0
- package/src/layouts/PrivateLayout/mcpSidebar.ts +35 -0
- package/src/layouts/PrivateLayout/sidebarItems.test.ts +58 -0
- package/src/layouts/PrivateLayout/sidebarItems.ts +38 -0
- package/src/layouts/PrivateLayout/types.ts +30 -7
- package/src/layouts/SettingsLayout/README.md +21 -0
- package/src/layouts/SettingsLayout/components/AvatarSection.tsx +0 -1
- package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
- package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +11 -8
- package/src/layouts/SettingsLayout/hooks/useSettingsSectionState.ts +32 -0
- package/src/layouts/SettingsLayout/index.ts +4 -0
- package/src/layouts/SettingsLayout/sectionIds.test-d.ts +51 -0
- package/src/layouts/SettingsLayout/sectionIds.test.ts +31 -0
- package/src/layouts/SettingsLayout/sectionIds.ts +43 -0
- package/src/testing/MockAuthFormProvider.tsx +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.603",
|
|
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.603",
|
|
95
|
+
"@djangocfg/api": "^2.1.603",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.603",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.603",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.603",
|
|
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.603",
|
|
129
|
+
"@djangocfg/api": "^2.1.603",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.603",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.603",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.603",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.603",
|
|
134
134
|
"@storybook/react-vite": "^10.5.0",
|
|
135
135
|
"@types/node": "^25.9.5",
|
|
136
136
|
"@types/react": "19.2.15",
|
|
@@ -81,7 +81,7 @@ export function ErrorButtons({ detail }: ErrorButtonsProps) {
|
|
|
81
81
|
size="sm"
|
|
82
82
|
variant="secondary"
|
|
83
83
|
onClick={handleCopyError}
|
|
84
|
-
className="
|
|
84
|
+
className="text-xs bg-background hover:bg-background/80 text-foreground border border-border gap-1.5"
|
|
85
85
|
>
|
|
86
86
|
<Copy className="h-3.5 w-3.5" />
|
|
87
87
|
Copy Error
|
|
@@ -92,7 +92,7 @@ export function ErrorButtons({ detail }: ErrorButtonsProps) {
|
|
|
92
92
|
size="sm"
|
|
93
93
|
variant="secondary"
|
|
94
94
|
onClick={handleCopyCurl}
|
|
95
|
-
className="
|
|
95
|
+
className="text-xs bg-background hover:bg-background/80 text-foreground border border-border gap-1.5"
|
|
96
96
|
>
|
|
97
97
|
<Terminal className="h-3.5 w-3.5" />
|
|
98
98
|
Copy cURL
|
|
@@ -278,6 +278,28 @@ calls and navigations for `play` assertions. Pair it with
|
|
|
278
278
|
parameters in the address bar and the stored state in `sessionStorage`
|
|
279
279
|
(`seedOAuthBrowser`), as a real return leaves them.
|
|
280
280
|
|
|
281
|
+
## A failed provider sign-in
|
|
282
|
+
|
|
283
|
+
A provider round trip that does not finish never blocks the page. `OAuthCallback`
|
|
284
|
+
strips `?provider=&code=&state=` from the address bar, puts the failure in
|
|
285
|
+
`AuthFormContext.oauthNotice`, and the visitor lands on the normal sign-in form
|
|
286
|
+
with a calm inline notice (`OAuthNotice`, `role="status"`) above the buttons. Only
|
|
287
|
+
the wait for the code exchange covers the page.
|
|
288
|
+
|
|
289
|
+
| Failure | The form says | One next step |
|
|
290
|
+
|---|---|---|
|
|
291
|
+
| Cancelled on the provider's page | nothing | the form itself |
|
|
292
|
+
| `link_not_allowed` (staff account) | "Use the email code for this address" + one sentence | **Continue with email**: closes the notice, focuses the email field |
|
|
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
|
+
| Email missing or unverified, domain refused | one sentence | **Continue with email** |
|
|
295
|
+
| Account disabled, rate limited | one sentence | none |
|
|
296
|
+
|
|
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
|
|
300
|
+
`layouts.auth.oauth.notice.*` in `@djangocfg/i18n`. The strict-mode guard is
|
|
301
|
+
unchanged: one attempt per page load.
|
|
302
|
+
|
|
281
303
|
## Errors
|
|
282
304
|
|
|
283
305
|
The form never shows a raw transport string. `@djangocfg/api`'s
|
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Loader2 } from 'lucide-react';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
5
|
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
beginTwoFactorChallenge, clearOAuthAttempt, isOAuthProviderId, type OAuthFailureCode,
|
|
9
|
-
useOAuthLogin
|
|
8
|
+
beginTwoFactorChallenge, clearOAuthAttempt, isOAuthProviderId, type OAuthFailureCode, useOAuthLogin
|
|
10
9
|
} from '@djangocfg/api/auth';
|
|
11
10
|
import { useAppT } from '@djangocfg/i18n';
|
|
12
|
-
import {
|
|
13
|
-
Card, CardContent, CardDescription, CardHeader, CardTitle
|
|
14
|
-
} from '@djangocfg/ui-core/components';
|
|
11
|
+
import { Card, CardDescription, CardHeader, CardTitle } from '@djangocfg/ui-core/components';
|
|
15
12
|
|
|
16
13
|
import { useAuthFormContext } from '../../context';
|
|
17
14
|
import { readOAuthCallback, stripOAuthCallbackParams } from './oauthCallbackParams';
|
|
18
15
|
import { useOAuthErrorText } from './oauthErrors';
|
|
16
|
+
import { describeOAuthFailure } from './oauthNoticeCopy';
|
|
19
17
|
|
|
20
18
|
export interface OAuthCallbackProps {
|
|
21
19
|
/** `user` is the provider callback's user payload, untyped upstream — narrow it before reading fields. */
|
|
@@ -24,13 +22,7 @@ export interface OAuthCallbackProps {
|
|
|
24
22
|
redirectUrl?: string;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
type CallbackStatus = 'idle' | 'processing' | '
|
|
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
|
-
}
|
|
25
|
+
type CallbackStatus = 'idle' | 'processing' | 'challenge';
|
|
34
26
|
|
|
35
27
|
/**
|
|
36
28
|
* Finishes a provider sign-in on the page the provider returned to.
|
|
@@ -38,8 +30,9 @@ interface Failure {
|
|
|
38
30
|
* Mounted by `AuthLayout`; it renders nothing unless the URL carries
|
|
39
31
|
* `?provider=<id>`. It reads the URL once, strips the single-use parameters
|
|
40
32
|
* from the address bar, then either exchanges the code (the stored `state`
|
|
41
|
-
* must match, see `useOAuthLogin`) or
|
|
42
|
-
*
|
|
33
|
+
* must match, see `useOAuthLogin`) or hands the failure (Cancel, a provider
|
|
34
|
+
* error, an unknown provider) to the sign-in form as a dismissible notice.
|
|
35
|
+
* The page is never blocked by a failure: the visitor lands on the form.
|
|
43
36
|
*
|
|
44
37
|
* Success hands over to the layout's success screen, which consumes the saved
|
|
45
38
|
* return URL; this component never navigates itself.
|
|
@@ -49,14 +42,12 @@ interface Failure {
|
|
|
49
42
|
*/
|
|
50
43
|
function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProps) {
|
|
51
44
|
const searchParams = useSearchParams();
|
|
52
|
-
const {
|
|
53
|
-
const { setStep } = useAuthFormContext();
|
|
45
|
+
const { setStep, setOAuthNotice } = useAuthFormContext();
|
|
54
46
|
const t = useAppT();
|
|
55
47
|
const errorText = useOAuthErrorText();
|
|
56
48
|
|
|
57
49
|
const [status, setStatus] = useState<CallbackStatus>('idle');
|
|
58
50
|
const [provider, setProvider] = useState<string | null>(null);
|
|
59
|
-
const [failure, setFailure] = useState<Failure | null>(null);
|
|
60
51
|
|
|
61
52
|
// One attempt per page load: strict mode runs effects twice, and the
|
|
62
53
|
// stripped URL re-renders this component with no provider left to read.
|
|
@@ -64,15 +55,12 @@ function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProp
|
|
|
64
55
|
|
|
65
56
|
const fail = useCallback(
|
|
66
57
|
(providerId: string, code: OAuthFailureCode | undefined, message?: string) => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
});
|
|
72
|
-
setStatus('error');
|
|
73
|
-
onError?.(text);
|
|
58
|
+
// Cancel is not an error: the visitor is simply back at the form.
|
|
59
|
+
if (describeOAuthFailure(code)) setOAuthNotice({ provider: providerId, code });
|
|
60
|
+
setStatus('idle');
|
|
61
|
+
onError?.(errorText(code, message));
|
|
74
62
|
},
|
|
75
|
-
[errorText, onError],
|
|
63
|
+
[errorText, setOAuthNotice, onError],
|
|
76
64
|
);
|
|
77
65
|
|
|
78
66
|
const failRef = useRef(fail);
|
|
@@ -115,47 +103,24 @@ function OAuthCallbackRaw({ onSuccess, onError, redirectUrl }: OAuthCallbackProp
|
|
|
115
103
|
void complete(intent.provider, intent.code, intent.state);
|
|
116
104
|
}, [searchParams, complete, fail]);
|
|
117
105
|
|
|
118
|
-
if (status
|
|
106
|
+
if (status !== 'processing') return null;
|
|
119
107
|
|
|
120
|
-
// Success is the layout's success screen (setStep('success'))
|
|
121
|
-
//
|
|
108
|
+
// Success is the layout's success screen (setStep('success')), a failure is
|
|
109
|
+
// the notice on the form; this overlay covers only the wait for the exchange.
|
|
122
110
|
return (
|
|
123
111
|
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
|
124
112
|
<Card className="w-full max-w-md mx-4 shadow-lg">
|
|
125
113
|
<CardHeader className="text-center">
|
|
126
|
-
|
|
127
|
-
<div
|
|
128
|
-
<
|
|
129
|
-
<Loader2 className="w-6 h-6 text-primary animate-spin" aria-hidden="true" />
|
|
130
|
-
</div>
|
|
131
|
-
<CardTitle>{t('layouts.auth.oauth.signingIn')}</CardTitle>
|
|
132
|
-
<CardDescription>
|
|
133
|
-
{t('layouts.auth.oauth.signingInBody', { provider: providerLabel(provider) })}
|
|
134
|
-
</CardDescription>
|
|
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" />
|
|
135
117
|
</div>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<AlertCircle className="w-6 h-6 text-destructive" aria-hidden="true" />
|
|
142
|
-
</div>
|
|
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>
|
|
149
|
-
)}
|
|
118
|
+
<CardTitle>{t('layouts.auth.oauth.signingIn')}</CardTitle>
|
|
119
|
+
<CardDescription>
|
|
120
|
+
{t('layouts.auth.oauth.signingInBody', { provider: providerLabel(provider) })}
|
|
121
|
+
</CardDescription>
|
|
122
|
+
</div>
|
|
150
123
|
</CardHeader>
|
|
151
|
-
|
|
152
|
-
{status === 'error' && (
|
|
153
|
-
<CardContent className="text-center">
|
|
154
|
-
<a href={routes.auth} className="text-primary hover:underline text-sm" autoFocus>
|
|
155
|
-
{t('layouts.auth.oauth.tryAgain')}
|
|
156
|
-
</a>
|
|
157
|
-
</CardContent>
|
|
158
|
-
)}
|
|
159
124
|
</Card>
|
|
160
125
|
</div>
|
|
161
126
|
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Info, X } from 'lucide-react';
|
|
4
|
+
import React, { memo, useCallback, useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
import { isOAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
|
|
7
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
+
import { Alert, AlertDescription, AlertTitle, Button } from '@djangocfg/ui-core/components';
|
|
9
|
+
|
|
10
|
+
import type { OAuthFailure } from '../../types';
|
|
11
|
+
import { describeOAuthFailure } from './oauthNoticeCopy';
|
|
12
|
+
|
|
13
|
+
export interface OAuthNoticeProps {
|
|
14
|
+
failure: OAuthFailure;
|
|
15
|
+
sourceUrl?: string;
|
|
16
|
+
/** Closes the notice (button or Escape). */
|
|
17
|
+
onDismiss: () => void;
|
|
18
|
+
/** Takes the visitor to the email field; the notice closes with it. */
|
|
19
|
+
onUseEmail: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Brand names are not translated; an id we do not know is shown as it came. */
|
|
23
|
+
const providerLabel = (id: string): string => {
|
|
24
|
+
if (id === 'github') return 'GitHub';
|
|
25
|
+
if (id === 'google') return 'Google';
|
|
26
|
+
return id;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the sign-in form says after a provider round trip failed: one calm
|
|
31
|
+
* sentence, at most one next step, and a way to close it. Not a dialog: the
|
|
32
|
+
* form underneath stays usable and focus stays where it was.
|
|
33
|
+
*
|
|
34
|
+
* `role="status"` (polite): the visitor did nothing wrong, so it informs
|
|
35
|
+
* without interrupting. Renders nothing for a cancelled sign-in.
|
|
36
|
+
*/
|
|
37
|
+
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoticeProps) {
|
|
38
|
+
const t = useAppT();
|
|
39
|
+
const copy = describeOAuthFailure(failure.code);
|
|
40
|
+
const provider = providerLabel(failure.provider);
|
|
41
|
+
const canRetry = isOAuthProviderId(failure.provider);
|
|
42
|
+
|
|
43
|
+
const { start, isLoading } = useOAuthLogin({ sourceUrl });
|
|
44
|
+
const handleRetry = useCallback(() => {
|
|
45
|
+
if (isOAuthProviderId(failure.provider)) void start(failure.provider);
|
|
46
|
+
}, [start, failure.provider]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
50
|
+
if (event.key === 'Escape') onDismiss();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener('keydown', onKeyDown);
|
|
53
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
54
|
+
}, [onDismiss]);
|
|
55
|
+
|
|
56
|
+
if (!copy) return null;
|
|
57
|
+
|
|
58
|
+
// A retry for a provider this client has no button for would go nowhere: send the visitor to email instead.
|
|
59
|
+
const action = copy.action === 'retry' && !canRetry ? 'email' : copy.action;
|
|
60
|
+
const title = copy.titleKey ? t(copy.titleKey) : null;
|
|
61
|
+
const body = t(copy.bodyKey, { provider });
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Alert role="status" className="auth-oauth-notice relative pr-10">
|
|
65
|
+
<Info className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
66
|
+
<div>
|
|
67
|
+
{title && <AlertTitle>{title}</AlertTitle>}
|
|
68
|
+
<AlertDescription className="text-muted-foreground">{body}</AlertDescription>
|
|
69
|
+
{action === 'email' && (
|
|
70
|
+
<Button type="button" size="sm" className="mt-3" onClick={onUseEmail}>
|
|
71
|
+
{t('layouts.auth.oauth.notice.useEmail')}
|
|
72
|
+
</Button>
|
|
73
|
+
)}
|
|
74
|
+
{action === 'retry' && (
|
|
75
|
+
<Button type="button" size="sm" className="mt-3" disabled={isLoading} onClick={handleRetry}>
|
|
76
|
+
{t('layouts.auth.oauth.notice.retry', { provider })}
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
<Button
|
|
81
|
+
type="button"
|
|
82
|
+
variant="ghost"
|
|
83
|
+
size="icon"
|
|
84
|
+
className="absolute right-1 top-1 h-8 w-8"
|
|
85
|
+
aria-label={t('layouts.auth.oauth.notice.dismiss')}
|
|
86
|
+
onClick={onDismiss}
|
|
87
|
+
>
|
|
88
|
+
<X className="h-4 w-4" aria-hidden="true" />
|
|
89
|
+
</Button>
|
|
90
|
+
</Alert>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const OAuthNotice = memo(OAuthNoticeRaw);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { OAuthCallback, type OAuthCallbackProps } from './OAuthCallback';
|
|
2
2
|
export { OAuthButtons, type OAuthButtonsProps } from './OAuthButtons';
|
|
3
3
|
export { resolveOAuthProviders } from './oauthProviders';
|
|
4
|
+
export { OAuthNotice, type OAuthNoticeProps } from './OAuthNotice';
|
|
@@ -5,48 +5,7 @@ import { useCallback } from 'react';
|
|
|
5
5
|
import type { OAuthFailureCode } from '@djangocfg/api/auth';
|
|
6
6
|
import { useAppT } from '@djangocfg/i18n';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
* Copy for each failure code. Exhaustive on purpose: a code the backend adds
|
|
10
|
-
* fails typecheck here until someone decides what to tell the visitor.
|
|
11
|
-
*/
|
|
12
|
-
export const OAUTH_ERROR_KEYS = {
|
|
13
|
-
invalid_state: 'layouts.auth.oauth.error.invalid_state',
|
|
14
|
-
oauth_exchange_failed: 'layouts.auth.oauth.error.oauth_exchange_failed',
|
|
15
|
-
invalid_id_token: 'layouts.auth.oauth.error.invalid_id_token',
|
|
16
|
-
email_missing: 'layouts.auth.oauth.error.email_missing',
|
|
17
|
-
email_not_verified: 'layouts.auth.oauth.error.email_not_verified',
|
|
18
|
-
hosted_domain_not_allowed: 'layouts.auth.oauth.error.hosted_domain_not_allowed',
|
|
19
|
-
account_inactive: 'layouts.auth.oauth.error.account_inactive',
|
|
20
|
-
link_not_allowed: 'layouts.auth.oauth.error.link_not_allowed',
|
|
21
|
-
rate_limited: 'layouts.auth.oauth.error.rate_limited',
|
|
22
|
-
provider_unavailable: 'layouts.auth.oauth.error.provider_unavailable',
|
|
23
|
-
cancelled: 'layouts.auth.oauth.error.cancelled',
|
|
24
|
-
generic: 'layouts.auth.oauth.error.generic',
|
|
25
|
-
} as const;
|
|
26
|
-
|
|
27
|
-
type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
|
|
28
|
-
|
|
29
|
-
/** Maps every failure code to one of the keys above. Configuration faults the visitor cannot fix read as "unavailable" or generic. */
|
|
30
|
-
export const OAUTH_CODE_TO_KEY: Record<OAuthFailureCode, OAuthErrorKey> = {
|
|
31
|
-
invalid_state: OAUTH_ERROR_KEYS.invalid_state,
|
|
32
|
-
oauth_exchange_failed: OAUTH_ERROR_KEYS.oauth_exchange_failed,
|
|
33
|
-
invalid_id_token: OAUTH_ERROR_KEYS.invalid_id_token,
|
|
34
|
-
email_missing: OAUTH_ERROR_KEYS.email_missing,
|
|
35
|
-
email_not_verified: OAUTH_ERROR_KEYS.email_not_verified,
|
|
36
|
-
hosted_domain_not_allowed: OAUTH_ERROR_KEYS.hosted_domain_not_allowed,
|
|
37
|
-
account_inactive: OAUTH_ERROR_KEYS.account_inactive,
|
|
38
|
-
link_not_allowed: OAUTH_ERROR_KEYS.link_not_allowed,
|
|
39
|
-
rate_limited: OAUTH_ERROR_KEYS.rate_limited,
|
|
40
|
-
provider_unavailable: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
41
|
-
cancelled: OAUTH_ERROR_KEYS.cancelled,
|
|
42
|
-
oauth_disabled: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
43
|
-
github_oauth_disabled: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
44
|
-
origin_not_allowed: OAUTH_ERROR_KEYS.generic,
|
|
45
|
-
missing_redirect_uri: OAUTH_ERROR_KEYS.generic,
|
|
46
|
-
server_error: OAUTH_ERROR_KEYS.generic,
|
|
47
|
-
not_found: OAUTH_ERROR_KEYS.generic,
|
|
48
|
-
unknown_provider: OAUTH_ERROR_KEYS.generic,
|
|
49
|
-
};
|
|
8
|
+
import { OAUTH_CODE_TO_KEY, OAUTH_ERROR_KEYS } from './oauthNoticeCopy';
|
|
50
9
|
|
|
51
10
|
/**
|
|
52
11
|
* The visitor-facing sentence for a failure: the translated copy of its code,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { describeOAuthFailure } from './oauthNoticeCopy';
|
|
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
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('offers a retry where trying again can help', () => {
|
|
19
|
+
for (const code of ['invalid_state', 'oauth_exchange_failed', 'provider_unavailable', 'server_error'] as const) {
|
|
20
|
+
expect(describeOAuthFailure(code)?.action).toBe('retry');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('offers nothing the visitor cannot act on', () => {
|
|
25
|
+
expect(describeOAuthFailure('account_inactive')?.action).toBe('none');
|
|
26
|
+
expect(describeOAuthFailure('rate_limited')?.action).toBe('none');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('treats a missing code as the generic failure', () => {
|
|
30
|
+
expect(describeOAuthFailure(undefined)).toEqual({
|
|
31
|
+
titleKey: undefined,
|
|
32
|
+
bodyKey: 'layouts.auth.oauth.error.generic',
|
|
33
|
+
action: 'retry',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { OAuthFailureCode } from '@djangocfg/api/auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copy for each failure code. Exhaustive on purpose: a code the backend adds
|
|
5
|
+
* fails typecheck here until someone decides what to tell the visitor.
|
|
6
|
+
*/
|
|
7
|
+
export const OAUTH_ERROR_KEYS = {
|
|
8
|
+
invalid_state: 'layouts.auth.oauth.error.invalid_state',
|
|
9
|
+
oauth_exchange_failed: 'layouts.auth.oauth.error.oauth_exchange_failed',
|
|
10
|
+
invalid_id_token: 'layouts.auth.oauth.error.invalid_id_token',
|
|
11
|
+
email_missing: 'layouts.auth.oauth.error.email_missing',
|
|
12
|
+
email_not_verified: 'layouts.auth.oauth.error.email_not_verified',
|
|
13
|
+
hosted_domain_not_allowed: 'layouts.auth.oauth.error.hosted_domain_not_allowed',
|
|
14
|
+
account_inactive: 'layouts.auth.oauth.error.account_inactive',
|
|
15
|
+
link_not_allowed: 'layouts.auth.oauth.error.link_not_allowed',
|
|
16
|
+
rate_limited: 'layouts.auth.oauth.error.rate_limited',
|
|
17
|
+
provider_unavailable: 'layouts.auth.oauth.error.provider_unavailable',
|
|
18
|
+
cancelled: 'layouts.auth.oauth.error.cancelled',
|
|
19
|
+
generic: 'layouts.auth.oauth.error.generic',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
|
|
23
|
+
|
|
24
|
+
/** Maps every failure code to one of the keys above. Configuration faults the visitor cannot fix read as "unavailable" or generic. */
|
|
25
|
+
export const OAUTH_CODE_TO_KEY: Record<OAuthFailureCode, OAuthErrorKey> = {
|
|
26
|
+
invalid_state: OAUTH_ERROR_KEYS.invalid_state,
|
|
27
|
+
oauth_exchange_failed: OAUTH_ERROR_KEYS.oauth_exchange_failed,
|
|
28
|
+
invalid_id_token: OAUTH_ERROR_KEYS.invalid_id_token,
|
|
29
|
+
email_missing: OAUTH_ERROR_KEYS.email_missing,
|
|
30
|
+
email_not_verified: OAUTH_ERROR_KEYS.email_not_verified,
|
|
31
|
+
hosted_domain_not_allowed: OAUTH_ERROR_KEYS.hosted_domain_not_allowed,
|
|
32
|
+
account_inactive: OAUTH_ERROR_KEYS.account_inactive,
|
|
33
|
+
link_not_allowed: OAUTH_ERROR_KEYS.link_not_allowed,
|
|
34
|
+
rate_limited: OAUTH_ERROR_KEYS.rate_limited,
|
|
35
|
+
provider_unavailable: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
36
|
+
cancelled: OAUTH_ERROR_KEYS.cancelled,
|
|
37
|
+
oauth_disabled: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
38
|
+
github_oauth_disabled: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
39
|
+
origin_not_allowed: OAUTH_ERROR_KEYS.generic,
|
|
40
|
+
missing_redirect_uri: OAUTH_ERROR_KEYS.generic,
|
|
41
|
+
server_error: OAUTH_ERROR_KEYS.generic,
|
|
42
|
+
not_found: OAUTH_ERROR_KEYS.generic,
|
|
43
|
+
unknown_provider: OAUTH_ERROR_KEYS.generic,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The one next step a notice offers: reveal the email field, run the provider again, or nothing the visitor can do. */
|
|
47
|
+
export type OAuthNoticeAction = 'email' | 'retry' | 'none';
|
|
48
|
+
|
|
49
|
+
/** What the visitor does next, per copy key. A retry is only offered where trying again can change the outcome. */
|
|
50
|
+
const ACTION_BY_KEY: Record<OAuthErrorKey, OAuthNoticeAction> = {
|
|
51
|
+
[OAUTH_ERROR_KEYS.invalid_state]: 'retry',
|
|
52
|
+
[OAUTH_ERROR_KEYS.oauth_exchange_failed]: 'retry',
|
|
53
|
+
[OAUTH_ERROR_KEYS.invalid_id_token]: 'retry',
|
|
54
|
+
[OAUTH_ERROR_KEYS.email_missing]: 'email',
|
|
55
|
+
[OAUTH_ERROR_KEYS.email_not_verified]: 'email',
|
|
56
|
+
[OAUTH_ERROR_KEYS.hosted_domain_not_allowed]: 'email',
|
|
57
|
+
[OAUTH_ERROR_KEYS.account_inactive]: 'none',
|
|
58
|
+
[OAUTH_ERROR_KEYS.link_not_allowed]: 'email',
|
|
59
|
+
[OAUTH_ERROR_KEYS.rate_limited]: 'none',
|
|
60
|
+
[OAUTH_ERROR_KEYS.provider_unavailable]: 'retry',
|
|
61
|
+
[OAUTH_ERROR_KEYS.cancelled]: 'none',
|
|
62
|
+
[OAUTH_ERROR_KEYS.generic]: 'retry',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface OAuthNoticeCopy {
|
|
66
|
+
/** Only the code that needs a heading has one; the rest are a single sentence. */
|
|
67
|
+
titleKey?: 'layouts.auth.oauth.notice.linkNotAllowedTitle';
|
|
68
|
+
bodyKey: OAuthErrorKey;
|
|
69
|
+
action: OAuthNoticeAction;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What the sign-in form tells a visitor whose provider round trip failed.
|
|
74
|
+
*
|
|
75
|
+
* `null` means "say nothing": the visitor cancelled on the provider's page and
|
|
76
|
+
* is back at the form they started from. An absent code is the unknown case.
|
|
77
|
+
*/
|
|
78
|
+
export const describeOAuthFailure = (code?: OAuthFailureCode | null): OAuthNoticeCopy | null => {
|
|
79
|
+
const bodyKey = code ? OAUTH_CODE_TO_KEY[code] : OAUTH_ERROR_KEYS.generic;
|
|
80
|
+
if (bodyKey === OAUTH_ERROR_KEYS.cancelled) return null;
|
|
81
|
+
return {
|
|
82
|
+
titleKey: bodyKey === OAUTH_ERROR_KEYS.link_not_allowed ? 'layouts.auth.oauth.notice.linkNotAllowedTitle' : undefined,
|
|
83
|
+
bodyKey,
|
|
84
|
+
action: ACTION_BY_KEY[bodyKey],
|
|
85
|
+
};
|
|
86
|
+
};
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { memo, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useAppT } from '@djangocfg/i18n';
|
|
6
6
|
import { Checkbox, Input } from '@djangocfg/ui-core/components';
|
|
7
7
|
|
|
8
8
|
import { useAuthLayoutContext } from '../../layoutContext';
|
|
9
9
|
import { useAuthFormContext } from '../../context';
|
|
10
|
-
import { OAuthButtons } from '../oauth';
|
|
10
|
+
import { OAuthButtons, OAuthNotice } from '../oauth';
|
|
11
11
|
import {
|
|
12
12
|
AuthButton,
|
|
13
13
|
AuthConsent,
|
|
@@ -46,6 +46,8 @@ function IdentifierStepRaw() {
|
|
|
46
46
|
termsUrl,
|
|
47
47
|
privacyUrl,
|
|
48
48
|
oauth,
|
|
49
|
+
oauthNotice,
|
|
50
|
+
setOAuthNotice,
|
|
49
51
|
sourceUrl,
|
|
50
52
|
marketingConsent,
|
|
51
53
|
marketingConsentConfig,
|
|
@@ -106,6 +108,13 @@ function IdentifierStepRaw() {
|
|
|
106
108
|
}
|
|
107
109
|
}, [oauth.isReady, hasOAuth]);
|
|
108
110
|
|
|
111
|
+
// The notice's one next step: close it and put the cursor in the email field.
|
|
112
|
+
const dismissNotice = useCallback(() => setOAuthNotice(null), [setOAuthNotice]);
|
|
113
|
+
const useEmail = useCallback(() => {
|
|
114
|
+
setOAuthNotice(null);
|
|
115
|
+
emailRef.current?.focus();
|
|
116
|
+
}, [setOAuthNotice]);
|
|
117
|
+
|
|
109
118
|
return (
|
|
110
119
|
<AuthContainer step="identifier">
|
|
111
120
|
{!hideHeader && (
|
|
@@ -117,6 +126,10 @@ function IdentifierStepRaw() {
|
|
|
117
126
|
/>
|
|
118
127
|
)}
|
|
119
128
|
|
|
129
|
+
{oauthNotice && (
|
|
130
|
+
<OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} onUseEmail={useEmail} />
|
|
131
|
+
)}
|
|
132
|
+
|
|
120
133
|
{hasOAuth && (
|
|
121
134
|
<>
|
|
122
135
|
<OAuthButtons
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useContext, useMemo } from 'react';
|
|
3
|
+
import React, { createContext, useContext, useMemo, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useAuthForm, useOAuthProviders } from '@djangocfg/api/auth';
|
|
6
6
|
|
|
7
7
|
import { resolveOAuthProviders } from './components/oauth/oauthProviders';
|
|
8
|
-
import type { AuthFormContextType, AuthLayoutProps } from './types';
|
|
8
|
+
import type { AuthFormContextType, AuthLayoutProps, OAuthFailure } from './types';
|
|
9
9
|
|
|
10
10
|
// Exported so test/mock providers (storybook) can supply their own value
|
|
11
11
|
// without re-implementing AuthFormProvider end-to-end.
|
|
@@ -48,6 +48,8 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
48
48
|
[serverProviders, oauthProviders, oauthReady],
|
|
49
49
|
);
|
|
50
50
|
|
|
51
|
+
const [oauthNotice, setOAuthNotice] = useState<OAuthFailure | null>(null);
|
|
52
|
+
|
|
51
53
|
// Use the auth form hook
|
|
52
54
|
|
|
53
55
|
const authForm = useAuthForm({
|
|
@@ -72,6 +74,8 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
72
74
|
termsUrl,
|
|
73
75
|
privacyUrl,
|
|
74
76
|
oauth,
|
|
77
|
+
oauthNotice,
|
|
78
|
+
setOAuthNotice,
|
|
75
79
|
logo,
|
|
76
80
|
redirectUrl,
|
|
77
81
|
// Renamed: `marketingConsent` (the name) is the checkbox STATE from the form hook.
|
|
@@ -84,6 +88,7 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
84
88
|
termsUrl,
|
|
85
89
|
privacyUrl,
|
|
86
90
|
oauth,
|
|
91
|
+
oauthNotice,
|
|
87
92
|
logo,
|
|
88
93
|
redirectUrl,
|
|
89
94
|
marketingConsent,
|
|
@@ -18,7 +18,7 @@ export type {
|
|
|
18
18
|
UseAuthFormOptions,
|
|
19
19
|
} from '@djangocfg/api/auth';
|
|
20
20
|
|
|
21
|
-
import type { AuthFormReturn, OAuthProviderId } from '@djangocfg/api/auth';
|
|
21
|
+
import type { AuthFormReturn, OAuthFailureCode, OAuthProviderId } from '@djangocfg/api/auth';
|
|
22
22
|
|
|
23
23
|
import type { AuthBackgroundConfig, AuthShellVariant } from './shells';
|
|
24
24
|
|
|
@@ -93,6 +93,15 @@ export interface AuthFormContextType
|
|
|
93
93
|
/** False only while the server's list is first loading. */
|
|
94
94
|
isReady: boolean;
|
|
95
95
|
};
|
|
96
|
+
/** The failed provider round trip the form should explain, until the visitor dismisses it. */
|
|
97
|
+
oauthNotice: OAuthFailure | null;
|
|
98
|
+
setOAuthNotice: (failure: OAuthFailure | null) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A provider sign-in that did not finish. `code` is absent when the cause is unknown. */
|
|
102
|
+
export interface OAuthFailure {
|
|
103
|
+
provider: string;
|
|
104
|
+
code?: OAuthFailureCode;
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
// ─────────────────────────────────────────────────────────────────────────────
|