@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,33 @@
1
+ /**
2
+ * Provider sign-in (components/oauth). The buttons themselves are theme-token
3
+ * utilities in `OAuthProviderButton`; this file holds only what a utility
4
+ * cannot: the column, the room held while the provider list loads, and the
5
+ * developer hint. Sizes are tokens so the reserve and the buttons agree.
6
+ */
7
+
8
+ :root {
9
+ /* A provider button is at least this tall (above the 44px touch minimum). */
10
+ --auth-oauth-height: 3rem;
11
+ --auth-oauth-gap: 0.75rem;
12
+ }
13
+
14
+ .auth-oauth {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: var(--auth-oauth-gap);
18
+ }
19
+
20
+ /* Holds the room of two buttons, the gap and the divider while the server's
21
+ provider list loads, so the email field does not jump when it arrives. */
22
+ .auth-oauth-reserve {
23
+ height: calc(2 * var(--auth-oauth-height) + var(--auth-oauth-gap) + var(--auth-gap) + 1rem);
24
+ }
25
+
26
+ /* The line for whoever deploys the site, under a notice about configuration. */
27
+ .auth-oauth-hint {
28
+ margin-top: 0.5rem;
29
+ color: var(--auth-muted);
30
+ font-size: 0.75rem;
31
+ line-height: 1.4;
32
+ overflow-wrap: anywhere;
33
+ }
@@ -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
- };