@djangocfg/layouts 2.1.602 → 2.1.604

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.602",
3
+ "version": "2.1.604",
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.602",
95
- "@djangocfg/api": "^2.1.602",
96
- "@djangocfg/devtools": "^2.1.602",
97
- "@djangocfg/i18n": "^2.1.602",
98
- "@djangocfg/ui-core": "^2.1.602",
94
+ "@djangocfg/analytics": "^2.1.604",
95
+ "@djangocfg/api": "^2.1.604",
96
+ "@djangocfg/devtools": "^2.1.604",
97
+ "@djangocfg/i18n": "^2.1.604",
98
+ "@djangocfg/ui-core": "^2.1.604",
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.602",
129
- "@djangocfg/api": "^2.1.602",
130
- "@djangocfg/devtools": "^2.1.602",
131
- "@djangocfg/i18n": "^2.1.602",
132
- "@djangocfg/typescript-config": "^2.1.602",
133
- "@djangocfg/ui-core": "^2.1.602",
128
+ "@djangocfg/analytics": "^2.1.604",
129
+ "@djangocfg/api": "^2.1.604",
130
+ "@djangocfg/devtools": "^2.1.604",
131
+ "@djangocfg/i18n": "^2.1.604",
132
+ "@djangocfg/typescript-config": "^2.1.604",
133
+ "@djangocfg/ui-core": "^2.1.604",
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="h-8 text-xs bg-background hover:bg-background/80 text-foreground border border-border gap-1.5"
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="h-8 text-xs bg-background hover:bg-background/80 text-foreground border border-border gap-1.5"
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 { AlertCircle, Loader2 } from 'lucide-react';
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, useAuth,
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' | '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
- }
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 shows why there is no code (Cancel,
42
- * a provider error, an unknown provider).
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 { routes } = useAuth();
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
- const text = errorText(code, message);
68
- setFailure({
69
- message: text,
70
- browserHint: providerId === 'google' && (code === undefined || code === 'server_error'),
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 === 'idle' || status === 'challenge') return null;
106
+ if (status !== 'processing') return null;
119
107
 
120
- // Success is the layout's success screen (setStep('success')); this overlay
121
- // only covers waiting and failing.
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
- {status === 'processing' && (
127
- <div role="status" aria-live="polite">
128
- <div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
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
- {status === 'error' && (
139
- <div role="alert">
140
- <div className="mx-auto w-12 h-12 bg-destructive/10 rounded-full flex items-center justify-center mb-4">
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,
@@ -38,6 +38,7 @@ export type {
38
38
  // Layout UI types (owned by this package)
39
39
  AuthLayoutConfig,
40
40
  AuthFormContextType,
41
+ OAuthFailure,
41
42
  AuthLayoutProps,
42
43
  AuthHelpProps,
43
44
  // Shell types
@@ -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
  // ─────────────────────────────────────────────────────────────────────────────
@@ -123,7 +123,6 @@ export const AvatarSection = () => {
123
123
  size="sm"
124
124
  onClick={handlePick}
125
125
  disabled={isUploading}
126
- className="h-8"
127
126
  >
128
127
  {isUploading ? 'Uploading…' : 'Change avatar'}
129
128
  </Button>
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  import { AuthFormContext } from '../layouts/AuthLayout/context';
14
14
  import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/oauthProviders';
15
- import type { AuthFormContextType, AuthLayoutConfig } from '../layouts/AuthLayout/types';
15
+ import type { AuthFormContextType, AuthLayoutConfig, OAuthFailure } from '../layouts/AuthLayout/types';
16
16
 
17
17
  // Storybook-only provider. Reproduces the AuthFormContext shape with
18
18
  // in-memory state and no-op async handlers, so step components and the
@@ -117,6 +117,7 @@ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
117
117
  );
118
118
 
119
119
  const isAutoSubmittingFromUrl = useRef(false);
120
+ const [oauthNotice, setOAuthNotice] = useState<OAuthFailure | null>(null);
120
121
 
121
122
  const value: AuthFormContextType = useMemo(() => {
122
123
  const stop = (e: React.FormEvent) => {
@@ -177,6 +178,8 @@ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
177
178
  termsUrl,
178
179
  privacyUrl,
179
180
  oauth,
181
+ oauthNotice,
182
+ setOAuthNotice,
180
183
  logo,
181
184
  redirectUrl,
182
185
  marketingConsentConfig,
@@ -197,6 +200,7 @@ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
197
200
  termsUrl,
198
201
  privacyUrl,
199
202
  oauth,
203
+ oauthNotice,
200
204
  logo,
201
205
  redirectUrl,
202
206
  marketingConsentConfig,