@djangocfg/layouts 2.1.605 → 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/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,95 @@
|
|
|
1
|
+
import type { OAuthFailureCode } from '@djangocfg/api/auth';
|
|
2
|
+
|
|
3
|
+
/** Translation keys of the visitor-facing sentences, one per kind of failure. */
|
|
4
|
+
export const OAUTH_ERROR_KEYS = {
|
|
5
|
+
invalid_state: 'layouts.auth.oauth.error.invalid_state',
|
|
6
|
+
oauth_exchange_failed: 'layouts.auth.oauth.error.oauth_exchange_failed',
|
|
7
|
+
invalid_id_token: 'layouts.auth.oauth.error.invalid_id_token',
|
|
8
|
+
email_missing: 'layouts.auth.oauth.error.email_missing',
|
|
9
|
+
email_not_verified: 'layouts.auth.oauth.error.email_not_verified',
|
|
10
|
+
hosted_domain_not_allowed: 'layouts.auth.oauth.error.hosted_domain_not_allowed',
|
|
11
|
+
account_inactive: 'layouts.auth.oauth.error.account_inactive',
|
|
12
|
+
link_not_allowed: 'layouts.auth.oauth.error.link_not_allowed',
|
|
13
|
+
rate_limited: 'layouts.auth.oauth.error.rate_limited',
|
|
14
|
+
provider_unavailable: 'layouts.auth.oauth.error.provider_unavailable',
|
|
15
|
+
not_configured: 'layouts.auth.oauth.error.not_configured',
|
|
16
|
+
cancelled: 'layouts.auth.oauth.error.cancelled',
|
|
17
|
+
generic: 'layouts.auth.oauth.error.generic',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
|
|
21
|
+
|
|
22
|
+
/** The one next step a notice offers: reveal the email field, run the provider again, or nothing the visitor can do. */
|
|
23
|
+
export type OAuthNoticeAction = 'email' | 'retry' | 'none';
|
|
24
|
+
|
|
25
|
+
interface Rule {
|
|
26
|
+
/** Which sentence the visitor reads. */
|
|
27
|
+
copy: keyof typeof OAUTH_ERROR_KEYS;
|
|
28
|
+
/** What they can do about it. A retry is only offered where trying again can change the outcome. */
|
|
29
|
+
action: OAuthNoticeAction;
|
|
30
|
+
/** Site misconfiguration: a developer line is shown under the sentence. */
|
|
31
|
+
developerHint?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const retry = (copy: Rule['copy']): Rule => ({ copy, action: 'retry' });
|
|
35
|
+
const email = (copy: Rule['copy']): Rule => ({ copy, action: 'email' });
|
|
36
|
+
const nothing = (copy: Rule['copy']): Rule => ({ copy, action: 'none' });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every failure code, once: what is said and what is offered.
|
|
40
|
+
*
|
|
41
|
+
* Exhaustive on purpose: a code the backend adds fails typecheck here until
|
|
42
|
+
* someone decides what to tell the visitor. Faults only a developer can fix
|
|
43
|
+
* (`origin_not_allowed`, a missing redirect) read as "not available on this
|
|
44
|
+
* site" and point at email.
|
|
45
|
+
*/
|
|
46
|
+
const RULES: Record<OAuthFailureCode, Rule> = {
|
|
47
|
+
invalid_state: retry('invalid_state'),
|
|
48
|
+
oauth_exchange_failed: retry('oauth_exchange_failed'),
|
|
49
|
+
invalid_id_token: retry('invalid_id_token'),
|
|
50
|
+
email_missing: email('email_missing'),
|
|
51
|
+
email_not_verified: email('email_not_verified'),
|
|
52
|
+
hosted_domain_not_allowed: email('hosted_domain_not_allowed'),
|
|
53
|
+
account_inactive: nothing('account_inactive'),
|
|
54
|
+
link_not_allowed: email('link_not_allowed'),
|
|
55
|
+
rate_limited: nothing('rate_limited'),
|
|
56
|
+
provider_unavailable: retry('provider_unavailable'),
|
|
57
|
+
cancelled: nothing('cancelled'),
|
|
58
|
+
oauth_disabled: email('not_configured'),
|
|
59
|
+
github_oauth_disabled: email('not_configured'),
|
|
60
|
+
origin_not_allowed: { copy: 'not_configured', action: 'email', developerHint: true },
|
|
61
|
+
missing_redirect_uri: email('not_configured'),
|
|
62
|
+
server_error: retry('generic'),
|
|
63
|
+
not_found: retry('generic'),
|
|
64
|
+
unknown_provider: retry('generic'),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The sentence for a code; the generic one when the cause is unknown. */
|
|
68
|
+
export const oauthErrorKey = (code?: OAuthFailureCode | null): OAuthErrorKey =>
|
|
69
|
+
OAUTH_ERROR_KEYS[code ? RULES[code].copy : 'generic'];
|
|
70
|
+
|
|
71
|
+
export interface OAuthNoticeCopy {
|
|
72
|
+
/** Only the code that needs a heading has one; the rest are a single sentence. */
|
|
73
|
+
titleKey?: 'layouts.auth.oauth.notice.linkNotAllowedTitle';
|
|
74
|
+
bodyKey: OAuthErrorKey;
|
|
75
|
+
action: OAuthNoticeAction;
|
|
76
|
+
/** Present when the page's own configuration is at fault: a line for whoever deploys it. */
|
|
77
|
+
developerHintKey?: 'layouts.auth.oauth.notice.originNotAllowedHint';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What the sign-in form tells a visitor whose provider round trip failed.
|
|
82
|
+
*
|
|
83
|
+
* `null` means "say nothing": the visitor cancelled on the provider's page and
|
|
84
|
+
* is back at the form they started from. An absent code is the unknown case.
|
|
85
|
+
*/
|
|
86
|
+
export const describeOAuthFailure = (code?: OAuthFailureCode | null): OAuthNoticeCopy | null => {
|
|
87
|
+
const rule: Rule = code ? RULES[code] : retry('generic');
|
|
88
|
+
if (rule.copy === 'cancelled') return null;
|
|
89
|
+
return {
|
|
90
|
+
titleKey: rule.copy === 'link_not_allowed' ? 'layouts.auth.oauth.notice.linkNotAllowedTitle' : undefined,
|
|
91
|
+
bodyKey: OAUTH_ERROR_KEYS[rule.copy],
|
|
92
|
+
action: rule.action,
|
|
93
|
+
developerHintKey: rule.developerHint ? 'layouts.auth.oauth.notice.originNotAllowedHint' : undefined,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
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 { providerName } from '../providers/registry';
|
|
9
|
+
import { OAUTH_ERROR_KEYS, oauthErrorKey } from './errorCopy';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The visitor-facing sentence for a failure: the translated copy of its code
|
|
13
|
+
* (`{provider}` filled with the brand name), else the text the hook carried
|
|
14
|
+
* (server prose), else the generic sentence.
|
|
15
|
+
*/
|
|
16
|
+
export const useOAuthErrorText = () => {
|
|
17
|
+
const t = useAppT();
|
|
18
|
+
return useCallback(
|
|
19
|
+
(code?: OAuthFailureCode | null, fallback?: string | null, provider?: string | null): string => {
|
|
20
|
+
if (code) return t(oauthErrorKey(code), { provider: providerName(provider ?? '') });
|
|
21
|
+
return fallback || t(OAUTH_ERROR_KEYS.generic);
|
|
22
|
+
},
|
|
23
|
+
[t],
|
|
24
|
+
);
|
|
25
|
+
};
|
|
@@ -1,4 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Provider sign-in (GitHub, Google, ...). One folder, one job per subfolder:
|
|
3
|
+
*
|
|
4
|
+
* providers/ what a provider is: registry (name, icon), icons, which ones to show
|
|
5
|
+
* buttons/ the buttons: one provider button, the list, the list wired to the hook
|
|
6
|
+
* notice/ the inline message after a failed round trip
|
|
7
|
+
* callback/ finishing the round trip on /auth?provider=<id>: URL parsing, the wait overlay
|
|
8
|
+
* errors/ failure code -> copy and next step, in one table
|
|
9
|
+
*
|
|
10
|
+
* `OAuthSection` is what `IdentifierStep` mounts: notice, buttons, "or".
|
|
11
|
+
* Only `OAuthCallback` is part of the package's public API; the rest is internal.
|
|
12
|
+
* To add a provider, edit `providers/registry.ts` only.
|
|
13
|
+
*/
|
|
14
|
+
export { OAuthSection, type OAuthSectionProps } from './OAuthSection';
|
|
15
|
+
|
|
16
|
+
export { OAuthCallback, type OAuthCallbackProps } from './callback/OAuthCallback';
|
|
17
|
+
export { OAuthCallbackOverlay, type OAuthCallbackOverlayProps } from './callback/OAuthCallbackOverlay';
|
|
18
|
+
|
|
19
|
+
export { OAuthButtons, type OAuthButtonsProps } from './buttons/OAuthButtons';
|
|
20
|
+
export { OAuthButtonList, type OAuthButtonListProps } from './buttons/OAuthButtonList';
|
|
21
|
+
export { OAuthProviderButton, type OAuthProviderButtonProps } from './buttons/OAuthProviderButton';
|
|
22
|
+
|
|
23
|
+
export { OAuthNotice, type OAuthNoticeProps } from './notice/OAuthNotice';
|
|
24
|
+
export { OAuthNoticeView, type OAuthNoticeViewProps } from './notice/OAuthNoticeView';
|
|
25
|
+
|
|
26
|
+
export { describeOAuthFailure, oauthErrorKey, type OAuthNoticeCopy } from './errors/errorCopy';
|
|
27
|
+
export { useOAuthErrorText } from './errors/useOAuthErrorText';
|
|
28
|
+
|
|
29
|
+
export { OAUTH_PROVIDERS, providerName, resolveOAuthProviders, type OAuthProviderEntry } from './providers';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { memo, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { isOAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
|
|
6
|
+
|
|
7
|
+
import type { OAuthFailure } from '../../../types';
|
|
8
|
+
import { OAuthNoticeView } from './OAuthNoticeView';
|
|
9
|
+
|
|
10
|
+
export interface OAuthNoticeProps {
|
|
11
|
+
failure: OAuthFailure;
|
|
12
|
+
sourceUrl?: string;
|
|
13
|
+
/** Closes the notice (button or Escape). */
|
|
14
|
+
onDismiss: () => void;
|
|
15
|
+
/** Takes the visitor to the email field; the notice closes with it. */
|
|
16
|
+
onUseEmail: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The failure notice with its retry wired to `useOAuthLogin`. */
|
|
20
|
+
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoticeProps) {
|
|
21
|
+
const { start, isLoading } = useOAuthLogin({ sourceUrl });
|
|
22
|
+
const { provider } = failure;
|
|
23
|
+
|
|
24
|
+
const handleRetry = useCallback(() => {
|
|
25
|
+
if (isOAuthProviderId(provider)) void start(provider);
|
|
26
|
+
}, [start, provider]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<OAuthNoticeView
|
|
30
|
+
provider={provider}
|
|
31
|
+
code={failure.code}
|
|
32
|
+
origin={typeof window === 'undefined' ? undefined : window.location.origin}
|
|
33
|
+
onRetry={isOAuthProviderId(provider) ? handleRetry : undefined}
|
|
34
|
+
retrying={isLoading}
|
|
35
|
+
onDismiss={onDismiss}
|
|
36
|
+
onUseEmail={onUseEmail}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const OAuthNotice = memo(OAuthNoticeRaw);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { OAuthNoticeView, type OAuthNoticeViewProps } from './OAuthNoticeView';
|
|
7
|
+
|
|
8
|
+
vi.mock('@djangocfg/i18n', () => ({
|
|
9
|
+
useAppT: () => (key: string, params?: Record<string, string>) =>
|
|
10
|
+
params ? `${key} ${JSON.stringify(params)}` : key,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
function render(props: Partial<OAuthNoticeViewProps> = {}) {
|
|
16
|
+
const host = document.createElement('div');
|
|
17
|
+
document.body.append(host);
|
|
18
|
+
const root = createRoot(host);
|
|
19
|
+
const handlers = { onDismiss: vi.fn(), onUseEmail: vi.fn(), onRetry: vi.fn() };
|
|
20
|
+
act(() => root.render(<OAuthNoticeView provider="github" code="invalid_state" {...handlers} {...props} />));
|
|
21
|
+
return { host, ...handlers, buttons: () => [...host.querySelectorAll('button')] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('OAuthNoticeView', () => {
|
|
25
|
+
it('says nothing for a cancelled sign-in', () => {
|
|
26
|
+
expect(render({ code: 'cancelled' }).host.innerHTML).toBe('');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('is a polite status, not an alert', () => {
|
|
30
|
+
expect(render().host.querySelector('[role="status"]')).not.toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('offers a retry that runs the provider again', () => {
|
|
34
|
+
const { buttons, onRetry } = render();
|
|
35
|
+
const retry = buttons().find((b) => b.textContent!.includes('notice.retry'))!;
|
|
36
|
+
act(() => retry.click());
|
|
37
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('offers email instead of a retry when the provider has no button', () => {
|
|
41
|
+
const { buttons } = render({ onRetry: undefined });
|
|
42
|
+
expect(buttons().some((b) => b.textContent!.includes('notice.retry'))).toBe(false);
|
|
43
|
+
expect(buttons().some((b) => b.textContent === 'layouts.auth.oauth.notice.useEmail')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('closes with the dismiss button and with Escape', () => {
|
|
47
|
+
const { buttons, onDismiss } = render();
|
|
48
|
+
act(() => buttons().find((b) => b.getAttribute('aria-label') === 'layouts.auth.oauth.notice.dismiss')!.click());
|
|
49
|
+
act(() => {
|
|
50
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
51
|
+
});
|
|
52
|
+
expect(onDismiss).toHaveBeenCalledTimes(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('gives a developer the origin to allow when the origin is refused', () => {
|
|
56
|
+
const { host } = render({ code: 'origin_not_allowed', origin: 'https://app.example.com' });
|
|
57
|
+
expect(host.querySelector('.auth-oauth-hint')!.textContent).toContain('"origin":"https://app.example.com"');
|
|
58
|
+
expect(host.querySelector('.auth-oauth-hint')!.textContent).toContain('"id":"github"');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('shows no developer hint for other failures', () => {
|
|
62
|
+
expect(render({ origin: 'https://app.example.com' }).host.querySelector('.auth-oauth-hint')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/layouts/AuthLayout/components/oauth/{OAuthNotice.tsx → notice/OAuthNoticeView.tsx}
RENAMED
|
@@ -1,31 +1,30 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Info, X } from 'lucide-react';
|
|
4
|
-
import React, { memo,
|
|
4
|
+
import React, { memo, useEffect } from 'react';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { OAuthFailureCode } from '@djangocfg/api/auth';
|
|
7
7
|
import { useAppT } from '@djangocfg/i18n';
|
|
8
8
|
import { Alert, AlertDescription, AlertTitle, Button } from '@djangocfg/ui-core/components';
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import { describeOAuthFailure } from '../errors/errorCopy';
|
|
11
|
+
import { providerName } from '../providers/registry';
|
|
12
12
|
|
|
13
|
-
export interface
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
export interface OAuthNoticeViewProps {
|
|
14
|
+
/** Provider id as it came back in the URL. */
|
|
15
|
+
provider: string;
|
|
16
|
+
code?: OAuthFailureCode;
|
|
17
|
+
/** The page origin, shown in the developer hint for `origin_not_allowed`. */
|
|
18
|
+
origin?: string;
|
|
19
|
+
/** Runs the provider again. Absent when this client has no button for the provider: the notice offers email instead. */
|
|
20
|
+
onRetry?: () => void;
|
|
21
|
+
retrying?: boolean;
|
|
16
22
|
/** Closes the notice (button or Escape). */
|
|
17
23
|
onDismiss: () => void;
|
|
18
24
|
/** Takes the visitor to the email field; the notice closes with it. */
|
|
19
25
|
onUseEmail: () => void;
|
|
20
26
|
}
|
|
21
27
|
|
|
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
28
|
/**
|
|
30
29
|
* What the sign-in form says after a provider round trip failed: one calm
|
|
31
30
|
* sentence, at most one next step, and a way to close it. Not a dialog: the
|
|
@@ -34,16 +33,9 @@ const providerLabel = (id: string): string => {
|
|
|
34
33
|
* `role="status"` (polite): the visitor did nothing wrong, so it informs
|
|
35
34
|
* without interrupting. Renders nothing for a cancelled sign-in.
|
|
36
35
|
*/
|
|
37
|
-
function
|
|
36
|
+
function OAuthNoticeViewRaw({ provider, code, origin, onRetry, retrying = false, onDismiss, onUseEmail }: OAuthNoticeViewProps) {
|
|
38
37
|
const t = useAppT();
|
|
39
|
-
const copy = describeOAuthFailure(
|
|
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]);
|
|
38
|
+
const copy = describeOAuthFailure(code);
|
|
47
39
|
|
|
48
40
|
useEffect(() => {
|
|
49
41
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
@@ -55,25 +47,27 @@ function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoti
|
|
|
55
47
|
|
|
56
48
|
if (!copy) return null;
|
|
57
49
|
|
|
50
|
+
const name = providerName(provider);
|
|
58
51
|
// 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' && !
|
|
60
|
-
const title = copy.titleKey ? t(copy.titleKey) : null;
|
|
61
|
-
const body = t(copy.bodyKey, { provider });
|
|
52
|
+
const action = copy.action === 'retry' && !onRetry ? 'email' : copy.action;
|
|
62
53
|
|
|
63
54
|
return (
|
|
64
|
-
<Alert role="status" className="
|
|
55
|
+
<Alert role="status" className="relative pr-10">
|
|
65
56
|
<Info className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
66
57
|
<div>
|
|
67
|
-
{
|
|
68
|
-
<AlertDescription className="text-muted-foreground">{
|
|
58
|
+
{copy.titleKey && <AlertTitle>{t(copy.titleKey)}</AlertTitle>}
|
|
59
|
+
<AlertDescription className="text-muted-foreground">{t(copy.bodyKey, { provider: name })}</AlertDescription>
|
|
60
|
+
{copy.developerHintKey && origin && (
|
|
61
|
+
<p className="auth-oauth-hint">{t(copy.developerHintKey, { origin, id: provider })}</p>
|
|
62
|
+
)}
|
|
69
63
|
{action === 'email' && (
|
|
70
64
|
<Button type="button" size="sm" className="mt-3" onClick={onUseEmail}>
|
|
71
65
|
{t('layouts.auth.oauth.notice.useEmail')}
|
|
72
66
|
</Button>
|
|
73
67
|
)}
|
|
74
68
|
{action === 'retry' && (
|
|
75
|
-
<Button type="button" size="sm" className="mt-3" disabled={
|
|
76
|
-
{t('layouts.auth.oauth.notice.retry', { provider })}
|
|
69
|
+
<Button type="button" size="sm" className="mt-3" disabled={retrying} onClick={onRetry}>
|
|
70
|
+
{t('layouts.auth.oauth.notice.retry', { provider: name })}
|
|
77
71
|
</Button>
|
|
78
72
|
)}
|
|
79
73
|
</div>
|
|
@@ -91,4 +85,4 @@ function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoti
|
|
|
91
85
|
);
|
|
92
86
|
}
|
|
93
87
|
|
|
94
|
-
export const
|
|
88
|
+
export const OAuthNoticeView = memo(OAuthNoticeViewRaw);
|
package/src/layouts/AuthLayout/components/oauth/{GoogleIcon.tsx → providers/icons/GoogleIcon.tsx}
RENAMED
|
@@ -7,7 +7,7 @@ import React from 'react';
|
|
|
7
7
|
*/
|
|
8
8
|
export function GoogleIcon() {
|
|
9
9
|
return (
|
|
10
|
-
<svg
|
|
10
|
+
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
11
11
|
<path
|
|
12
12
|
fill="#EA4335"
|
|
13
13
|
d="M5.27 9.76A7.08 7.08 0 0 1 16.42 6.5L19.9 3A11.97 11.97 0 0 0 1.24 6.65l4.03 3.11Z"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { OAUTH_PROVIDERS, providerName } from './registry';
|
|
4
|
+
|
|
5
|
+
describe('providerName', () => {
|
|
6
|
+
it('returns the brand name of a known provider', () => {
|
|
7
|
+
expect(providerName('github')).toBe('GitHub');
|
|
8
|
+
expect(providerName('google')).toBe('Google');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('shows an unknown id as it came, never an inherited property', () => {
|
|
12
|
+
expect(providerName('gitlab')).toBe('gitlab');
|
|
13
|
+
expect(providerName('constructor')).toBe('constructor');
|
|
14
|
+
expect(providerName('')).toBe('');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('gives every provider an icon and a name', () => {
|
|
18
|
+
for (const entry of Object.values(OAUTH_PROVIDERS)) {
|
|
19
|
+
expect(entry.name).not.toBe('');
|
|
20
|
+
expect(typeof entry.Icon).toBe('function');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { OAuthProviderId } from '@djangocfg/api/auth';
|
|
4
|
+
|
|
5
|
+
import { GithubIcon } from './icons/GithubIcon';
|
|
6
|
+
import { GoogleIcon } from './icons/GoogleIcon';
|
|
7
|
+
|
|
8
|
+
export interface OAuthProviderEntry {
|
|
9
|
+
/** Brand name. Never translated: it fills `{provider}` in the button and message copy. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** The provider's mark, an svg component. */
|
|
12
|
+
Icon: React.FC;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The one place that knows what a provider looks like.
|
|
17
|
+
*
|
|
18
|
+
* Keyed by the API package's id type, so a provider the backend adds fails
|
|
19
|
+
* typecheck here until it has a name and an icon. Nothing else changes: the
|
|
20
|
+
* button list, labels, notices and error copy all read this table.
|
|
21
|
+
*
|
|
22
|
+
* To add a provider: draw `icons/<Name>Icon.tsx`, add one line below.
|
|
23
|
+
*/
|
|
24
|
+
export const OAUTH_PROVIDERS: Record<OAuthProviderId, OAuthProviderEntry> = {
|
|
25
|
+
github: { name: 'GitHub', Icon: GithubIcon },
|
|
26
|
+
google: { name: 'Google', Icon: GoogleIcon },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isKnown = (id: string): id is OAuthProviderId => Object.prototype.hasOwnProperty.call(OAUTH_PROVIDERS, id);
|
|
30
|
+
|
|
31
|
+
/** Brand name for an id; an id this client does not know is shown as it came. */
|
|
32
|
+
export const providerName = (id: string): string => (isKnown(id) ? OAUTH_PROVIDERS[id].name : id);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { resolveOAuthProviders } from './
|
|
3
|
+
import { resolveOAuthProviders } from './resolveProviders';
|
|
4
4
|
|
|
5
5
|
describe('resolveOAuthProviders', () => {
|
|
6
6
|
it('shows everything the server lists, in the server order', () => {
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
import React, { memo } from 'react';
|
|
4
4
|
|
|
5
5
|
export interface AuthButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
-
|
|
7
|
-
variant?: 'primary' | 'secondary' | 'google';
|
|
6
|
+
variant?: 'primary' | 'secondary';
|
|
8
7
|
loading?: boolean;
|
|
9
8
|
children: React.ReactNode;
|
|
10
9
|
}
|
|
@@ -7,12 +7,11 @@ import { Checkbox, Input } from '@djangocfg/ui-core/components';
|
|
|
7
7
|
|
|
8
8
|
import { useAuthLayoutContext } from '../../layoutContext';
|
|
9
9
|
import { useAuthFormContext } from '../../context';
|
|
10
|
-
import {
|
|
10
|
+
import { OAuthSection } from '../oauth';
|
|
11
11
|
import {
|
|
12
12
|
AuthButton,
|
|
13
13
|
AuthConsent,
|
|
14
14
|
AuthContainer,
|
|
15
|
-
AuthDivider,
|
|
16
15
|
AuthError,
|
|
17
16
|
AuthHeader,
|
|
18
17
|
} from '../shared';
|
|
@@ -46,8 +45,6 @@ function IdentifierStepRaw() {
|
|
|
46
45
|
termsUrl,
|
|
47
46
|
privacyUrl,
|
|
48
47
|
oauth,
|
|
49
|
-
oauthNotice,
|
|
50
|
-
setOAuthNotice,
|
|
51
48
|
sourceUrl,
|
|
52
49
|
marketingConsent,
|
|
53
50
|
marketingConsentConfig,
|
|
@@ -108,12 +105,8 @@ function IdentifierStepRaw() {
|
|
|
108
105
|
}
|
|
109
106
|
}, [oauth.isReady, hasOAuth]);
|
|
110
107
|
|
|
111
|
-
// The notice's one next step:
|
|
112
|
-
const
|
|
113
|
-
const useEmail = useCallback(() => {
|
|
114
|
-
setOAuthNotice(null);
|
|
115
|
-
emailRef.current?.focus();
|
|
116
|
-
}, [setOAuthNotice]);
|
|
108
|
+
// The notice's one next step: the cursor goes to the email field.
|
|
109
|
+
const focusEmail = useCallback(() => emailRef.current?.focus(), []);
|
|
117
110
|
|
|
118
111
|
return (
|
|
119
112
|
<AuthContainer step="identifier">
|
|
@@ -126,22 +119,7 @@ function IdentifierStepRaw() {
|
|
|
126
119
|
/>
|
|
127
120
|
)}
|
|
128
121
|
|
|
129
|
-
{
|
|
130
|
-
<OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} onUseEmail={useEmail} />
|
|
131
|
-
)}
|
|
132
|
-
|
|
133
|
-
{hasOAuth && (
|
|
134
|
-
<>
|
|
135
|
-
<OAuthButtons
|
|
136
|
-
providers={oauth.providers}
|
|
137
|
-
sourceUrl={sourceUrl}
|
|
138
|
-
disabled={isLoading}
|
|
139
|
-
onError={setError}
|
|
140
|
-
/>
|
|
141
|
-
<AuthDivider />
|
|
142
|
-
</>
|
|
143
|
-
)}
|
|
144
|
-
{!oauth.isReady && <div className="auth-oauth-reserve" aria-hidden="true" />}
|
|
122
|
+
<OAuthSection sourceUrl={sourceUrl} disabled={isLoading} onError={setError} onUseEmail={focusEmail} />
|
|
145
123
|
|
|
146
124
|
<form onSubmit={handleIdentifierSubmit} className="auth-form-group">
|
|
147
125
|
<Input
|
|
@@ -4,7 +4,7 @@ import React, { createContext, useContext, useMemo, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { useAuthForm, useOAuthProviders } from '@djangocfg/api/auth';
|
|
6
6
|
|
|
7
|
-
import { resolveOAuthProviders } from './components/oauth/
|
|
7
|
+
import { resolveOAuthProviders } from './components/oauth/providers/resolveProviders';
|
|
8
8
|
import type { AuthFormContextType, AuthLayoutProps, OAuthFailure } from './types';
|
|
9
9
|
|
|
10
10
|
// Exported so test/mock providers (storybook) can supply their own value
|
|
@@ -322,9 +322,9 @@ a.auth-logo:focus-visible {
|
|
|
322
322
|
color: var(--auth-muted);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
/*
|
|
326
|
-
|
|
327
|
-
|
|
325
|
+
/* Secondary: a quiet outline button so the primary path stays the single
|
|
326
|
+
loudest action. `--secondary` in light is near-black, which made the
|
|
327
|
+
secondary path shout louder than the primary one — HIG wants one accent. */
|
|
328
328
|
.auth-button-secondary {
|
|
329
329
|
background: transparent;
|
|
330
330
|
color: var(--foreground);
|
|
@@ -344,43 +344,6 @@ a.auth-logo:focus-visible {
|
|
|
344
344
|
opacity: 0.88;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
/* Google: Google's branding rules, not the app theme. Light uses a white fill
|
|
348
|
-
with a 1px #747775 stroke and #1F1F1F text; dark uses #131314, #8E918F and
|
|
349
|
-
#E3E3E3. Padding is 12px before the logo, 10px to the text, 12px after.
|
|
350
|
-
Weight and size come from the app font (Google Sans cannot be loaded). Keyed
|
|
351
|
-
on `.dark` because the logo and text must not follow `--foreground`. */
|
|
352
|
-
.auth-button-google {
|
|
353
|
-
gap: 0.625rem;
|
|
354
|
-
padding: 0 0.75rem;
|
|
355
|
-
font-size: 0.875rem;
|
|
356
|
-
line-height: 1.25rem;
|
|
357
|
-
font-weight: 500;
|
|
358
|
-
letter-spacing: 0;
|
|
359
|
-
background: #ffffff;
|
|
360
|
-
color: #1f1f1f;
|
|
361
|
-
border: 1px solid #747775;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
.dark .auth-button-google {
|
|
365
|
-
background: #131314;
|
|
366
|
-
color: #e3e3e3;
|
|
367
|
-
border-color: #8e918f;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
.auth-button-google:disabled {
|
|
371
|
-
opacity: 0.45;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
.auth-button-google.auth-button-loading::after {
|
|
375
|
-
border-color: #747775;
|
|
376
|
-
border-right-color: transparent;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
.dark .auth-button-google.auth-button-loading::after {
|
|
380
|
-
border-color: #8e918f;
|
|
381
|
-
border-right-color: transparent;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
347
|
/* Loading state */
|
|
385
348
|
.auth-button-loading {
|
|
386
349
|
color: transparent !important;
|
|
@@ -388,10 +351,6 @@ a.auth-logo:focus-visible {
|
|
|
388
351
|
pointer-events: none;
|
|
389
352
|
}
|
|
390
353
|
|
|
391
|
-
.auth-button-loading > .auth-oauth-icon {
|
|
392
|
-
opacity: 0;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
354
|
.auth-button-loading::after {
|
|
396
355
|
content: "";
|
|
397
356
|
position: absolute;
|
|
@@ -417,32 +376,6 @@ a.auth-logo:focus-visible {
|
|
|
417
376
|
to { transform: rotate(360deg); }
|
|
418
377
|
}
|
|
419
378
|
|
|
420
|
-
.auth-oauth {
|
|
421
|
-
display: flex;
|
|
422
|
-
flex-direction: column;
|
|
423
|
-
gap: 0.75rem;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
.auth-oauth-icon {
|
|
427
|
-
flex: none;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/* The provider buttons share one type size and weight (Google's 14/20 medium),
|
|
431
|
-
so GitHub is never the louder of the two. */
|
|
432
|
-
.auth-oauth .auth-button {
|
|
433
|
-
gap: 0.625rem;
|
|
434
|
-
font-size: 0.875rem;
|
|
435
|
-
line-height: 1.25rem;
|
|
436
|
-
font-weight: 500;
|
|
437
|
-
letter-spacing: 0;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/* Holds the room of two buttons, the gap and the divider while the server's
|
|
441
|
-
provider list loads, so the email field does not jump when it arrives. */
|
|
442
|
-
.auth-oauth-reserve {
|
|
443
|
-
height: calc(2 * 3rem + 0.75rem + var(--auth-gap) + 1rem);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
379
|
/* ===== DIVIDER ===== */
|
|
447
380
|
|
|
448
381
|
.auth-divider {
|