@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.
Files changed (37) 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/testing/MockAuthFormProvider.tsx +1 -1
  32. package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +0 -82
  33. package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +0 -23
  34. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +0 -36
  35. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +0 -86
  36. /package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.ts → callback/callbackParams.ts} +0 -0
  37. /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
- export { OAuthCallback, type OAuthCallbackProps } from './OAuthCallback';
2
- export { OAuthButtons, type OAuthButtonsProps } from './OAuthButtons';
3
- export { resolveOAuthProviders } from './oauthProviders';
4
- export { OAuthNotice, type OAuthNoticeProps } from './OAuthNotice';
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
+ });
@@ -1,31 +1,30 @@
1
1
  'use client';
2
2
 
3
3
  import { Info, X } from 'lucide-react';
4
- import React, { memo, useCallback, useEffect } from 'react';
4
+ import React, { memo, useEffect } from 'react';
5
5
 
6
- import { isOAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
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 type { OAuthFailure } from '../../types';
11
- import { describeOAuthFailure } from './oauthNoticeCopy';
10
+ import { describeOAuthFailure } from '../errors/errorCopy';
11
+ import { providerName } from '../providers/registry';
12
12
 
13
- export interface OAuthNoticeProps {
14
- failure: OAuthFailure;
15
- sourceUrl?: string;
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 OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoticeProps) {
36
+ function OAuthNoticeViewRaw({ provider, code, origin, onRetry, retrying = false, onDismiss, onUseEmail }: OAuthNoticeViewProps) {
38
37
  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]);
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' && !canRetry ? 'email' : copy.action;
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="auth-oauth-notice relative pr-10">
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
- {title && <AlertTitle>{title}</AlertTitle>}
68
- <AlertDescription className="text-muted-foreground">{body}</AlertDescription>
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={isLoading} onClick={handleRetry}>
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 OAuthNotice = memo(OAuthNoticeRaw);
88
+ export const OAuthNoticeView = memo(OAuthNoticeViewRaw);
@@ -7,7 +7,6 @@ import React from 'react';
7
7
  export function GithubIcon() {
8
8
  return (
9
9
  <svg
10
- className="auth-oauth-icon"
11
10
  width="20"
12
11
  height="20"
13
12
  viewBox="0 -3.1 2490.3 2493"
@@ -7,7 +7,7 @@ import React from 'react';
7
7
  */
8
8
  export function GoogleIcon() {
9
9
  return (
10
- <svg className="auth-oauth-icon" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
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,2 @@
1
+ export { OAUTH_PROVIDERS, providerName, type OAuthProviderEntry } from './registry';
2
+ export { resolveOAuthProviders } from './resolveProviders';
@@ -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 './oauthProviders';
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
- /** `google` follows Google's button branding and is not themed by `--secondary`. */
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 { OAuthButtons, OAuthNotice } from '../oauth';
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: 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]);
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
- {oauthNotice && (
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/oauthProviders';
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
- /* GitHub / OAuth: a quiet outline button so the email path (primary) stays
326
- the single loudest action. `--secondary` in light is near-black, which made
327
- the secondary path shout louder than the primary one — HIG wants one accent. */
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 {