@djangocfg/layouts 2.1.604 → 2.1.606

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +12 -12
  2. package/src/layouts/AuthLayout/AuthLayout.tsx +1 -0
  3. package/src/layouts/AuthLayout/README.md +20 -3
  4. package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +54 -0
  5. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.test.tsx +52 -0
  6. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.tsx +49 -0
  7. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtons.tsx +44 -0
  8. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.test.tsx +56 -0
  9. package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.tsx +51 -0
  10. package/src/layouts/AuthLayout/components/oauth/{OAuthCallback.tsx → callback/OAuthCallback.tsx} +7 -33
  11. package/src/layouts/AuthLayout/components/oauth/callback/OAuthCallbackOverlay.tsx +41 -0
  12. package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.test.ts → callback/callbackParams.test.ts} +1 -1
  13. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.test.ts +63 -0
  14. package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +95 -0
  15. package/src/layouts/AuthLayout/components/oauth/errors/useOAuthErrorText.ts +25 -0
  16. package/src/layouts/AuthLayout/components/oauth/index.ts +29 -4
  17. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +41 -0
  18. package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +64 -0
  19. package/src/layouts/AuthLayout/components/oauth/{OAuthNotice.tsx → notice/OAuthNoticeView.tsx} +26 -32
  20. package/src/layouts/AuthLayout/components/oauth/{GithubIcon.tsx → providers/icons/GithubIcon.tsx} +0 -1
  21. package/src/layouts/AuthLayout/components/oauth/{GoogleIcon.tsx → providers/icons/GoogleIcon.tsx} +1 -1
  22. package/src/layouts/AuthLayout/components/oauth/providers/index.ts +2 -0
  23. package/src/layouts/AuthLayout/components/oauth/providers/registry.test.ts +23 -0
  24. package/src/layouts/AuthLayout/components/oauth/providers/registry.ts +32 -0
  25. package/src/layouts/AuthLayout/components/oauth/{oauthProviders.test.ts → providers/resolveProviders.test.ts} +1 -1
  26. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +1 -2
  27. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +4 -26
  28. package/src/layouts/AuthLayout/context.tsx +1 -1
  29. package/src/layouts/AuthLayout/styles/auth.css +3 -70
  30. package/src/layouts/AuthLayout/styles/oauth.css +33 -0
  31. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +5 -9
  32. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +5 -2
  33. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +45 -0
  34. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +7 -2
  35. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +7 -23
  36. package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -2
  37. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -9
  38. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +31 -16
  39. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +2 -3
  40. package/src/layouts/PrivateLayout/geometry.ts +35 -2
  41. package/src/layouts/PrivateLayout/hooks/index.ts +4 -0
  42. package/src/layouts/PrivateLayout/hooks/useHoverIntent.test.tsx +100 -0
  43. package/src/layouts/PrivateLayout/hooks/useHoverIntent.ts +112 -0
  44. package/src/layouts/PrivateLayout/hooks/usePresence.ts +39 -0
  45. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +30 -85
  46. package/src/styles/dashboard.css +27 -0
  47. package/src/testing/MockAuthFormProvider.tsx +1 -1
  48. package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +0 -82
  49. package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +0 -23
  50. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +0 -36
  51. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +0 -86
  52. /package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.ts → callback/callbackParams.ts} +0 -0
  53. /package/src/layouts/AuthLayout/components/oauth/{oauthProviders.ts → providers/resolveProviders.ts} +0 -0
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Presence: keeps an element mounted for `exitMs` after `open` turns false, so its exit
3
+ * animation can run. With `prefers-reduced-motion` the exit is skipped (unmount at once).
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import { useEffect, useState } from 'react';
9
+
10
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
11
+
12
+ import { SIDEBAR_MOTION_MS } from '../geometry';
13
+
14
+ export interface Presence {
15
+ /** Render the element. */
16
+ mounted: boolean;
17
+ /** Drives `data-state`: `open` while entering/shown, `closed` while exiting. */
18
+ state: 'open' | 'closed';
19
+ }
20
+
21
+ export function usePresence(open: boolean, exitMs: number = SIDEBAR_MOTION_MS): Presence {
22
+ const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');
23
+ const [lingering, setLingering] = useState(false);
24
+
25
+ useEffect(() => {
26
+ if (open) {
27
+ setLingering(true);
28
+ return;
29
+ }
30
+ if (reduced || exitMs <= 0) {
31
+ setLingering(false);
32
+ return;
33
+ }
34
+ const t = setTimeout(() => setLingering(false), exitMs);
35
+ return () => clearTimeout(t);
36
+ }, [open, reduced, exitMs]);
37
+
38
+ return { mounted: open || lingering, state: open ? 'open' : 'closed' };
39
+ }
@@ -1,36 +1,27 @@
1
1
  /**
2
- * Peek: while the sidebar is collapsed, hovering its trigger zone floats the
3
- * full sidebar over the page. The panel keeps itself open while the pointer is
2
+ * Peek: while the sidebar is collapsed, resting the pointer on the left edge or the trigger
3
+ * zone (hover intent) floats the full sidebar over the page. The panel keeps itself open while the pointer is
4
4
  * on it, or while a menu opened from it (account, workspace switcher) is open.
5
5
  */
6
6
 
7
7
  'use client';
8
8
 
9
- import React, {
10
- createContext,
11
- useCallback,
12
- useContext,
13
- useEffect,
14
- useMemo,
15
- useRef,
16
- useState,
17
- } from 'react';
9
+ import React, { createContext, useContext, useEffect, useMemo } from 'react';
18
10
 
19
11
  import { useSidebar } from '@djangocfg/ui-core/components';
12
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
20
13
 
21
- // Opens on entry with no delay, then stays at least MIN_OPEN_MS even if the
22
- // pointer only grazed the zone, so a user who didn't notice it still sees it.
23
- const MIN_OPEN_MS = 900;
24
- const CLOSE_DELAY_MS = 300;
14
+ import { useHoverIntent } from './useHoverIntent';
15
+ import type { Presence } from './usePresence';
16
+ import { usePresence } from './usePresence';
25
17
 
26
- interface PointerHandlers {
27
- onPointerEnter: (e: React.PointerEvent) => void;
28
- onPointerLeave: (e: React.PointerEvent) => void;
29
- }
18
+ type PointerHandlers = ReturnType<typeof useHoverIntent>['zoneProps'];
30
19
 
31
- export interface SidebarPeek {
20
+ export interface SidebarPeek extends Presence {
32
21
  /** True while the sidebar is collapsed on desktop: the trigger zone shows. */
33
22
  enabled: boolean;
23
+ /** Hover-open is live: collapsed, desktop, and a fine (mouse/pen) pointer. */
24
+ hoverEnabled: boolean;
34
25
  open: boolean;
35
26
  zoneProps: PointerHandlers;
36
27
  panelProps: PointerHandlers;
@@ -64,68 +55,14 @@ interface SidebarPeekProviderProps {
64
55
  export function SidebarPeekProvider({ pathname, children }: SidebarPeekProviderProps) {
65
56
  const { state, isMobile } = useSidebar();
66
57
  const enabled = !isMobile && state === 'collapsed';
58
+ // Hover is a mouse affordance: only where the primary pointer can hover precisely.
59
+ const finePointer = useMediaQuery('(hover: hover) and (pointer: fine)');
60
+ const hoverEnabled = enabled && finePointer;
67
61
 
68
- const [open, setOpen] = useState(false);
69
- const pinned = useRef(false);
70
- const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
71
- const openedAt = useRef(0);
72
-
73
- const cancel = useCallback(() => {
74
- if (timer.current) clearTimeout(timer.current);
75
- timer.current = null;
76
- }, []);
77
-
78
- const scheduleClose = useCallback(() => {
79
- cancel();
80
- if (pinned.current) return;
81
- const grace = openedAt.current + MIN_OPEN_MS - Date.now();
82
- timer.current = setTimeout(() => setOpen(false), Math.max(CLOSE_DELAY_MS, grace));
83
- }, [cancel]);
84
-
85
- const show = useCallback(() => {
86
- cancel();
87
- setOpen((was) => {
88
- if (!was) openedAt.current = Date.now();
89
- return true;
90
- });
91
- }, [cancel]);
92
-
93
- const close = useCallback(() => {
94
- cancel();
95
- setOpen(false);
96
- }, [cancel]);
97
-
98
- const pin = useCallback(
99
- (value: boolean) => {
100
- pinned.current = value;
101
- if (!value) scheduleClose();
102
- },
103
- [scheduleClose],
104
- );
105
-
106
- // Hover is a mouse affordance; touch taps the trigger to dock instead.
107
- const zoneProps = useMemo<PointerHandlers>(
108
- () => ({
109
- onPointerEnter: (e) => {
110
- if (e.pointerType !== 'mouse') return;
111
- show();
112
- },
113
- onPointerLeave: scheduleClose,
114
- }),
115
- [show, scheduleClose],
116
- );
117
-
118
- const panelProps = useMemo<PointerHandlers>(
119
- () => ({
120
- onPointerEnter: show,
121
- onPointerLeave: scheduleClose,
122
- }),
123
- [show, scheduleClose],
124
- );
125
-
126
- useEffect(() => {
127
- if (!enabled) close();
128
- }, [enabled, close]);
62
+ const intent = useHoverIntent({ enabled: hoverEnabled });
63
+ const { close } = intent;
64
+ const open = enabled && intent.open;
65
+ const presence = usePresence(open);
129
66
 
130
67
  useEffect(() => {
131
68
  close();
@@ -140,11 +77,19 @@ export function SidebarPeekProvider({ pathname, children }: SidebarPeekProviderP
140
77
  return () => window.removeEventListener('keydown', onKey);
141
78
  }, [open, close]);
142
79
 
143
- useEffect(() => cancel, [cancel]);
144
-
145
80
  const value = useMemo<SidebarPeek>(
146
- () => ({ enabled, open: enabled && open, zoneProps, panelProps, close, pin }),
147
- [enabled, open, zoneProps, panelProps, close, pin],
81
+ () => ({
82
+ enabled,
83
+ hoverEnabled,
84
+ open,
85
+ mounted: enabled && presence.mounted,
86
+ state: presence.state,
87
+ zoneProps: intent.zoneProps,
88
+ panelProps: intent.panelProps,
89
+ close,
90
+ pin: intent.pin,
91
+ }),
92
+ [enabled, hoverEnabled, open, presence.mounted, presence.state, intent.zoneProps, intent.panelProps, close, intent.pin],
148
93
  );
149
94
 
150
95
  return <PeekContext.Provider value={value}>{children}</PeekContext.Provider>;
@@ -49,3 +49,30 @@
49
49
  [data-sidebar-resizing] [data-variant][data-state] * {
50
50
  transition: none !important;
51
51
  }
52
+
53
+
54
+ /**
55
+ * Private layout: peek panel slide in and out. Shipped here so consumers need no animation
56
+ * plugin. Duration mirrors SIDEBAR_MOTION_MS in PrivateLayout/geometry.ts.
57
+ */
58
+ @keyframes private-sidebar-in {
59
+ from { opacity: 0; transform: translateX(-1rem); }
60
+ to { opacity: 1; transform: translateX(0); }
61
+ }
62
+ @keyframes private-sidebar-out {
63
+ from { opacity: 1; transform: translateX(0); }
64
+ to { opacity: 0; transform: translateX(-1rem); }
65
+ }
66
+ .private-sidebar-peek[data-state="open"] {
67
+ animation: private-sidebar-in 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
68
+ }
69
+ .private-sidebar-peek[data-state="closed"] {
70
+ animation: private-sidebar-out 180ms ease-in both;
71
+ pointer-events: none;
72
+ }
73
+ @media (prefers-reduced-motion: reduce) {
74
+ .private-sidebar-peek[data-state="open"],
75
+ .private-sidebar-peek[data-state="closed"] {
76
+ animation: none;
77
+ }
78
+ }
@@ -11,7 +11,7 @@ import {
11
11
  } from '@djangocfg/api/auth';
12
12
 
13
13
  import { AuthFormContext } from '../layouts/AuthLayout/context';
14
- import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/oauthProviders';
14
+ import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/providers/resolveProviders';
15
15
  import type { AuthFormContextType, AuthLayoutConfig, OAuthFailure } from '../layouts/AuthLayout/types';
16
16
 
17
17
  // Storybook-only provider. Reproduces the AuthFormContext shape with
@@ -1,82 +0,0 @@
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);
@@ -1,23 +0,0 @@
1
- 'use client';
2
-
3
- import { useCallback } from 'react';
4
-
5
- import type { OAuthFailureCode } from '@djangocfg/api/auth';
6
- import { useAppT } from '@djangocfg/i18n';
7
-
8
- import { OAUTH_CODE_TO_KEY, OAUTH_ERROR_KEYS } from './oauthNoticeCopy';
9
-
10
- /**
11
- * The visitor-facing sentence for a failure: the translated copy of its code,
12
- * else the text the hook carried (server prose), else the generic sentence.
13
- */
14
- export const useOAuthErrorText = () => {
15
- const t = useAppT();
16
- return useCallback(
17
- (code?: OAuthFailureCode | null, fallback?: string | null): string => {
18
- if (code) return t(OAUTH_CODE_TO_KEY[code]);
19
- return fallback || t(OAUTH_ERROR_KEYS.generic);
20
- },
21
- [t],
22
- );
23
- };
@@ -1,36 +0,0 @@
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
- });
@@ -1,86 +0,0 @@
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
- };