@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.
- package/package.json +12 -12
- package/src/layouts/AuthLayout/AuthLayout.tsx +1 -0
- package/src/layouts/AuthLayout/README.md +20 -3
- package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +54 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.test.tsx +52 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.tsx +49 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtons.tsx +44 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.test.tsx +56 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.tsx +51 -0
- package/src/layouts/AuthLayout/components/oauth/{OAuthCallback.tsx → callback/OAuthCallback.tsx} +7 -33
- package/src/layouts/AuthLayout/components/oauth/callback/OAuthCallbackOverlay.tsx +41 -0
- package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.test.ts → callback/callbackParams.test.ts} +1 -1
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.test.ts +63 -0
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +95 -0
- package/src/layouts/AuthLayout/components/oauth/errors/useOAuthErrorText.ts +25 -0
- package/src/layouts/AuthLayout/components/oauth/index.ts +29 -4
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +41 -0
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +64 -0
- package/src/layouts/AuthLayout/components/oauth/{OAuthNotice.tsx → notice/OAuthNoticeView.tsx} +26 -32
- package/src/layouts/AuthLayout/components/oauth/{GithubIcon.tsx → providers/icons/GithubIcon.tsx} +0 -1
- package/src/layouts/AuthLayout/components/oauth/{GoogleIcon.tsx → providers/icons/GoogleIcon.tsx} +1 -1
- package/src/layouts/AuthLayout/components/oauth/providers/index.ts +2 -0
- package/src/layouts/AuthLayout/components/oauth/providers/registry.test.ts +23 -0
- package/src/layouts/AuthLayout/components/oauth/providers/registry.ts +32 -0
- package/src/layouts/AuthLayout/components/oauth/{oauthProviders.test.ts → providers/resolveProviders.test.ts} +1 -1
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +1 -2
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +4 -26
- package/src/layouts/AuthLayout/context.tsx +1 -1
- package/src/layouts/AuthLayout/styles/auth.css +3 -70
- package/src/layouts/AuthLayout/styles/oauth.css +33 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +5 -9
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +5 -2
- package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +45 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +7 -2
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +7 -23
- package/src/layouts/PrivateLayout/components/SidebarBrandSwitcher.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -9
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +31 -16
- package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +2 -3
- package/src/layouts/PrivateLayout/geometry.ts +35 -2
- package/src/layouts/PrivateLayout/hooks/index.ts +4 -0
- package/src/layouts/PrivateLayout/hooks/useHoverIntent.test.tsx +100 -0
- package/src/layouts/PrivateLayout/hooks/useHoverIntent.ts +112 -0
- package/src/layouts/PrivateLayout/hooks/usePresence.ts +39 -0
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +30 -85
- package/src/styles/dashboard.css +27 -0
- package/src/testing/MockAuthFormProvider.tsx +1 -1
- package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +0 -82
- package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +0 -23
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +0 -36
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +0 -86
- /package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.ts → callback/callbackParams.ts} +0 -0
- /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,
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
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
|
-
() => ({
|
|
147
|
-
|
|
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>;
|
package/src/styles/dashboard.css
CHANGED
|
@@ -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/
|
|
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
|
-
};
|
|
File without changes
|
/package/src/layouts/AuthLayout/components/oauth/{oauthProviders.ts → providers/resolveProviders.ts}
RENAMED
|
File without changes
|