@djangocfg/layouts 2.1.598 → 2.1.600
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/configurator/auth/defaults.ts +2 -0
- package/src/configurator/auth/schema.ts +4 -0
- package/src/configurator/auth/types.ts +1 -0
- package/src/layouts/AuthLayout/AuthLayout.tsx +9 -13
- package/src/layouts/AuthLayout/README.md +17 -2
- package/src/layouts/AuthLayout/components/oauth/GithubIcon.tsx +21 -0
- package/src/layouts/AuthLayout/components/oauth/GoogleIcon.tsx +29 -0
- package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +82 -0
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +101 -111
- package/src/layouts/AuthLayout/components/oauth/index.ts +2 -0
- package/src/layouts/AuthLayout/components/oauth/oauthCallbackParams.test.ts +76 -0
- package/src/layouts/AuthLayout/components/oauth/oauthCallbackParams.ts +70 -0
- package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +64 -0
- package/src/layouts/AuthLayout/components/oauth/oauthProviders.test.ts +27 -0
- package/src/layouts/AuthLayout/components/oauth/oauthProviders.ts +17 -0
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +4 -2
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +30 -28
- package/src/layouts/AuthLayout/context.tsx +15 -4
- package/src/layouts/AuthLayout/styles/auth.css +87 -11
- package/src/layouts/AuthLayout/types.ts +17 -3
- package/src/testing/MockAuthFormProvider.tsx +48 -7
- package/src/testing/index.ts +19 -0
- package/src/testing/mockOAuthApi.ts +230 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { readOAuthCallback, stripOAuthCallbackParams } from './oauthCallbackParams';
|
|
4
|
+
|
|
5
|
+
const isProvider = (value: string): value is 'github' | 'google' => value === 'github' || value === 'google';
|
|
6
|
+
const read = (query: string) => readOAuthCallback(new URLSearchParams(query), isProvider);
|
|
7
|
+
|
|
8
|
+
describe('readOAuthCallback', () => {
|
|
9
|
+
it('ignores a page that is not a callback', () => {
|
|
10
|
+
expect(read('')).toEqual({ kind: 'none' });
|
|
11
|
+
expect(read('code=abc&state=xyz')).toEqual({ kind: 'none' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('asks for the exchange when provider, code and state are present', () => {
|
|
15
|
+
expect(read('provider=google&code=c1&state=s1&scope=openid&authuser=0&prompt=none')).toEqual({
|
|
16
|
+
kind: 'exchange',
|
|
17
|
+
provider: 'google',
|
|
18
|
+
code: 'c1',
|
|
19
|
+
state: 's1',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('treats Cancel (access_denied, no code) as a cancellation', () => {
|
|
24
|
+
expect(read('provider=google&error=access_denied&state=s1')).toEqual({
|
|
25
|
+
kind: 'failure',
|
|
26
|
+
provider: 'google',
|
|
27
|
+
code: 'cancelled',
|
|
28
|
+
description: 'access_denied',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('carries the description of another provider error without naming a cause', () => {
|
|
33
|
+
expect(read('provider=github&error=server_error&error_description=Oops')).toEqual({
|
|
34
|
+
kind: 'failure',
|
|
35
|
+
provider: 'github',
|
|
36
|
+
code: undefined,
|
|
37
|
+
description: 'Oops',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('prefers the provider error over a code', () => {
|
|
42
|
+
expect(read('provider=google&error=access_denied&code=c1&state=s1').kind).toBe('failure');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects an unknown provider', () => {
|
|
46
|
+
expect(read('provider=myspace&code=c1&state=s1')).toMatchObject({
|
|
47
|
+
kind: 'failure',
|
|
48
|
+
provider: 'myspace',
|
|
49
|
+
code: 'unknown_provider',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects a callback without code or state as an invalid state', () => {
|
|
54
|
+
expect(read('provider=google&code=c1')).toMatchObject({ kind: 'failure', code: 'invalid_state' });
|
|
55
|
+
expect(read('provider=google&state=s1')).toMatchObject({ kind: 'failure', code: 'invalid_state' });
|
|
56
|
+
expect(read('provider=google')).toMatchObject({ kind: 'failure', code: 'invalid_state' });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('stripOAuthCallbackParams', () => {
|
|
61
|
+
it('removes every callback parameter and keeps the rest', () => {
|
|
62
|
+
const href =
|
|
63
|
+
'https://app.example.com/auth?provider=google&code=c1&state=s1&scope=openid+email&authuser=0&prompt=consent&hd=x.com&lang=de#top';
|
|
64
|
+
expect(stripOAuthCallbackParams(href)).toBe('/auth?lang=de#top');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('removes error parameters', () => {
|
|
68
|
+
expect(
|
|
69
|
+
stripOAuthCallbackParams('https://a.example/auth?provider=google&error=access_denied&error_description=x'),
|
|
70
|
+
).toBe('/auth');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('leaves a clean URL alone', () => {
|
|
74
|
+
expect(stripOAuthCallbackParams('https://a.example/auth#x/y?z=1')).toBe('/auth#x/y?z=1');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { OAuthFailureCode, OAuthProviderId } from '@djangocfg/api/auth';
|
|
2
|
+
|
|
3
|
+
/** Query parameters a provider round trip leaves on the callback URL. */
|
|
4
|
+
export const OAUTH_CALLBACK_PARAMS = [
|
|
5
|
+
'provider',
|
|
6
|
+
'code',
|
|
7
|
+
'state',
|
|
8
|
+
'scope',
|
|
9
|
+
'authuser',
|
|
10
|
+
'prompt',
|
|
11
|
+
'hd',
|
|
12
|
+
'iss',
|
|
13
|
+
'error',
|
|
14
|
+
'error_description',
|
|
15
|
+
'error_uri',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
/** What the callback URL asks the page to do. */
|
|
19
|
+
export type OAuthCallbackIntent =
|
|
20
|
+
| { kind: 'none' }
|
|
21
|
+
| { kind: 'exchange'; provider: OAuthProviderId; code: string; state: string }
|
|
22
|
+
/** The provider returned without a code. `code` is set when we know the cause (Cancel). */
|
|
23
|
+
| { kind: 'failure'; provider: string; code?: OAuthFailureCode; description?: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decide what a callback URL means. Pure: no storage, no network.
|
|
27
|
+
*
|
|
28
|
+
* `provider` is the only trigger, so a normal visit to the sign-in page is
|
|
29
|
+
* `none`. A provider `error` wins over a `code`: it is a refusal, whatever
|
|
30
|
+
* else rides along.
|
|
31
|
+
*/
|
|
32
|
+
export const readOAuthCallback = (
|
|
33
|
+
params: URLSearchParams,
|
|
34
|
+
isOAuthProviderId: (value: string) => value is OAuthProviderId,
|
|
35
|
+
): OAuthCallbackIntent => {
|
|
36
|
+
const provider = params.get('provider');
|
|
37
|
+
if (!provider) return { kind: 'none' };
|
|
38
|
+
|
|
39
|
+
if (!isOAuthProviderId(provider)) {
|
|
40
|
+
return { kind: 'failure', provider, code: 'unknown_provider' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const providerError = params.get('error');
|
|
44
|
+
if (providerError) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'failure',
|
|
47
|
+
provider,
|
|
48
|
+
// Google answers Cancel with `access_denied`; every other provider error is not ours to explain.
|
|
49
|
+
code: providerError === 'access_denied' ? 'cancelled' : undefined,
|
|
50
|
+
description: params.get('error_description') || providerError,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const code = params.get('code');
|
|
55
|
+
const state = params.get('state');
|
|
56
|
+
if (!code || !state) return { kind: 'failure', provider, code: 'invalid_state' };
|
|
57
|
+
|
|
58
|
+
return { kind: 'exchange', provider, code, state };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same URL without the callback parameters. The code and state are single
|
|
63
|
+
* use and must not stay in the address bar, history or a shared link; every
|
|
64
|
+
* other parameter and the hash are kept.
|
|
65
|
+
*/
|
|
66
|
+
export const stripOAuthCallbackParams = (href: string): string => {
|
|
67
|
+
const url = new URL(href);
|
|
68
|
+
for (const key of OAUTH_CALLBACK_PARAMS) url.searchParams.delete(key);
|
|
69
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
70
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Copy for each failure code. Exhaustive on purpose: a code the backend adds
|
|
10
|
+
* fails typecheck here until someone decides what to tell the visitor.
|
|
11
|
+
*/
|
|
12
|
+
export const OAUTH_ERROR_KEYS = {
|
|
13
|
+
invalid_state: 'layouts.auth.oauth.error.invalid_state',
|
|
14
|
+
oauth_exchange_failed: 'layouts.auth.oauth.error.oauth_exchange_failed',
|
|
15
|
+
invalid_id_token: 'layouts.auth.oauth.error.invalid_id_token',
|
|
16
|
+
email_missing: 'layouts.auth.oauth.error.email_missing',
|
|
17
|
+
email_not_verified: 'layouts.auth.oauth.error.email_not_verified',
|
|
18
|
+
hosted_domain_not_allowed: 'layouts.auth.oauth.error.hosted_domain_not_allowed',
|
|
19
|
+
account_inactive: 'layouts.auth.oauth.error.account_inactive',
|
|
20
|
+
link_not_allowed: 'layouts.auth.oauth.error.link_not_allowed',
|
|
21
|
+
rate_limited: 'layouts.auth.oauth.error.rate_limited',
|
|
22
|
+
provider_unavailable: 'layouts.auth.oauth.error.provider_unavailable',
|
|
23
|
+
cancelled: 'layouts.auth.oauth.error.cancelled',
|
|
24
|
+
generic: 'layouts.auth.oauth.error.generic',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
|
|
28
|
+
|
|
29
|
+
/** Maps every failure code to one of the keys above. Configuration faults the visitor cannot fix read as "unavailable" or generic. */
|
|
30
|
+
export const OAUTH_CODE_TO_KEY: Record<OAuthFailureCode, OAuthErrorKey> = {
|
|
31
|
+
invalid_state: OAUTH_ERROR_KEYS.invalid_state,
|
|
32
|
+
oauth_exchange_failed: OAUTH_ERROR_KEYS.oauth_exchange_failed,
|
|
33
|
+
invalid_id_token: OAUTH_ERROR_KEYS.invalid_id_token,
|
|
34
|
+
email_missing: OAUTH_ERROR_KEYS.email_missing,
|
|
35
|
+
email_not_verified: OAUTH_ERROR_KEYS.email_not_verified,
|
|
36
|
+
hosted_domain_not_allowed: OAUTH_ERROR_KEYS.hosted_domain_not_allowed,
|
|
37
|
+
account_inactive: OAUTH_ERROR_KEYS.account_inactive,
|
|
38
|
+
link_not_allowed: OAUTH_ERROR_KEYS.link_not_allowed,
|
|
39
|
+
rate_limited: OAUTH_ERROR_KEYS.rate_limited,
|
|
40
|
+
provider_unavailable: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
41
|
+
cancelled: OAUTH_ERROR_KEYS.cancelled,
|
|
42
|
+
oauth_disabled: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
43
|
+
github_oauth_disabled: OAUTH_ERROR_KEYS.provider_unavailable,
|
|
44
|
+
origin_not_allowed: OAUTH_ERROR_KEYS.generic,
|
|
45
|
+
missing_redirect_uri: OAUTH_ERROR_KEYS.generic,
|
|
46
|
+
server_error: OAUTH_ERROR_KEYS.generic,
|
|
47
|
+
not_found: OAUTH_ERROR_KEYS.generic,
|
|
48
|
+
unknown_provider: OAUTH_ERROR_KEYS.generic,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The visitor-facing sentence for a failure: the translated copy of its code,
|
|
53
|
+
* else the text the hook carried (server prose), else the generic sentence.
|
|
54
|
+
*/
|
|
55
|
+
export const useOAuthErrorText = () => {
|
|
56
|
+
const t = useAppT();
|
|
57
|
+
return useCallback(
|
|
58
|
+
(code?: OAuthFailureCode | null, fallback?: string | null): string => {
|
|
59
|
+
if (code) return t(OAUTH_CODE_TO_KEY[code]);
|
|
60
|
+
return fallback || t(OAUTH_ERROR_KEYS.generic);
|
|
61
|
+
},
|
|
62
|
+
[t],
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolveOAuthProviders } from './oauthProviders';
|
|
4
|
+
|
|
5
|
+
describe('resolveOAuthProviders', () => {
|
|
6
|
+
it('shows everything the server lists, in the server order', () => {
|
|
7
|
+
expect(resolveOAuthProviders(['github', 'google'])).toEqual(['github', 'google']);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('narrows to the requested ids', () => {
|
|
11
|
+
expect(resolveOAuthProviders(['github', 'google'], ['google'])).toEqual(['google']);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('orders by the request', () => {
|
|
15
|
+
expect(resolveOAuthProviders(['github', 'google'], ['google', 'github'])).toEqual(['google', 'github']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('never shows a provider the server did not list', () => {
|
|
19
|
+
expect(resolveOAuthProviders(['github'], ['google', 'github'])).toEqual(['github']);
|
|
20
|
+
expect(resolveOAuthProviders([], ['google'])).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('shows nothing for an empty request and drops duplicates', () => {
|
|
24
|
+
expect(resolveOAuthProviders(['github', 'google'], [])).toEqual([]);
|
|
25
|
+
expect(resolveOAuthProviders(['github', 'google'], ['google', 'google'])).toEqual(['google']);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OAuthProviderId } from '@djangocfg/api/auth';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The providers to show: the server's list, narrowed and ordered by the app's
|
|
5
|
+
* `oauthProviders` prop when it gives one.
|
|
6
|
+
*
|
|
7
|
+
* The server owns availability, so the prop can only remove or reorder; an id
|
|
8
|
+
* the server did not list is never shown, whatever the prop says.
|
|
9
|
+
*/
|
|
10
|
+
export const resolveOAuthProviders = (
|
|
11
|
+
server: readonly OAuthProviderId[],
|
|
12
|
+
requested?: readonly OAuthProviderId[],
|
|
13
|
+
): OAuthProviderId[] => {
|
|
14
|
+
if (!requested) return [...server];
|
|
15
|
+
const enabled = new Set(server);
|
|
16
|
+
return [...new Set(requested)].filter((id) => enabled.has(id));
|
|
17
|
+
};
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import React, { memo } from 'react';
|
|
4
4
|
|
|
5
5
|
export interface AuthButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
-
|
|
6
|
+
/** `google` follows Google's button branding and is not themed by `--secondary`. */
|
|
7
|
+
variant?: 'primary' | 'secondary' | 'google';
|
|
7
8
|
loading?: boolean;
|
|
8
9
|
children: React.ReactNode;
|
|
9
10
|
}
|
|
@@ -23,7 +24,7 @@ function AuthButtonRaw({
|
|
|
23
24
|
className = '',
|
|
24
25
|
...props
|
|
25
26
|
}: AuthButtonProps) {
|
|
26
|
-
const variantClass =
|
|
27
|
+
const variantClass = `auth-button-${variant}`;
|
|
27
28
|
const loadingClass = loading ? 'auth-button-loading' : '';
|
|
28
29
|
|
|
29
30
|
return (
|
|
@@ -31,6 +32,7 @@ function AuthButtonRaw({
|
|
|
31
32
|
type="submit"
|
|
32
33
|
className={`auth-button ${variantClass} ${loadingClass} ${className}`}
|
|
33
34
|
disabled={disabled || loading}
|
|
35
|
+
aria-busy={loading || undefined}
|
|
34
36
|
{...props}
|
|
35
37
|
>
|
|
36
38
|
{children}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Github } from 'lucide-react';
|
|
4
3
|
import React, { memo, useEffect, useMemo, useRef } from 'react';
|
|
5
4
|
|
|
6
|
-
import { useGithubAuth } from '@djangocfg/api/auth';
|
|
7
5
|
import { useAppT } from '@djangocfg/i18n';
|
|
8
6
|
import { Checkbox, Input } from '@djangocfg/ui-core/components';
|
|
9
7
|
|
|
10
8
|
import { useAuthLayoutContext } from '../../layoutContext';
|
|
11
9
|
import { useAuthFormContext } from '../../context';
|
|
10
|
+
import { OAuthButtons } from '../oauth';
|
|
12
11
|
import {
|
|
13
12
|
AuthButton,
|
|
14
13
|
AuthConsent,
|
|
@@ -24,8 +23,9 @@ import {
|
|
|
24
23
|
* Clean, minimal design with:
|
|
25
24
|
* - Optional logo, stood down on desktop when the media half carries the brand
|
|
26
25
|
* - Single email input field, with its error directly beneath it
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
26
|
+
* - Provider buttons first (only those the server enables), then "or", then
|
|
27
|
+
* the email path: one tap signs a returning visitor in
|
|
28
|
+
* - Passive consent line under both (continuing = consent; no opt-in checkbox)
|
|
29
29
|
*
|
|
30
30
|
* Memoised: this component has no props; it reads everything from
|
|
31
31
|
* context. The memo wrapper prevents re-renders when the parent
|
|
@@ -45,7 +45,7 @@ function IdentifierStepRaw() {
|
|
|
45
45
|
logo,
|
|
46
46
|
termsUrl,
|
|
47
47
|
privacyUrl,
|
|
48
|
-
|
|
48
|
+
oauth,
|
|
49
49
|
sourceUrl,
|
|
50
50
|
marketingConsent,
|
|
51
51
|
marketingConsentConfig,
|
|
@@ -92,16 +92,19 @@ function IdentifierStepRaw() {
|
|
|
92
92
|
email: t('layouts.auth.identifier.placeholder.email'),
|
|
93
93
|
},
|
|
94
94
|
button: t('layouts.auth.identifier.button'),
|
|
95
|
-
oauth: {
|
|
96
|
-
github: t('layouts.auth.identifier.oauth.github'),
|
|
97
|
-
},
|
|
98
95
|
}), [t]);
|
|
99
96
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
const hasOAuth = oauth.providers.length > 0;
|
|
98
|
+
|
|
99
|
+
// With buttons above it, the field must not grab focus: on a phone the
|
|
100
|
+
// keyboard would cover them. Without providers the page behaves as it always
|
|
101
|
+
// did, once we know that (the list arrives after mount).
|
|
102
|
+
const emailRef = useRef<HTMLInputElement>(null);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (oauth.isReady && !hasOAuth && document.activeElement === document.body) {
|
|
105
|
+
emailRef.current?.focus();
|
|
106
|
+
}
|
|
107
|
+
}, [oauth.isReady, hasOAuth]);
|
|
105
108
|
|
|
106
109
|
return (
|
|
107
110
|
<AuthContainer step="identifier">
|
|
@@ -114,8 +117,22 @@ function IdentifierStepRaw() {
|
|
|
114
117
|
/>
|
|
115
118
|
)}
|
|
116
119
|
|
|
120
|
+
{hasOAuth && (
|
|
121
|
+
<>
|
|
122
|
+
<OAuthButtons
|
|
123
|
+
providers={oauth.providers}
|
|
124
|
+
sourceUrl={sourceUrl}
|
|
125
|
+
disabled={isLoading}
|
|
126
|
+
onError={setError}
|
|
127
|
+
/>
|
|
128
|
+
<AuthDivider />
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
{!oauth.isReady && <div className="auth-oauth-reserve" aria-hidden="true" />}
|
|
132
|
+
|
|
117
133
|
<form onSubmit={handleIdentifierSubmit} className="auth-form-group">
|
|
118
134
|
<Input
|
|
135
|
+
ref={emailRef}
|
|
119
136
|
type="email"
|
|
120
137
|
name="email"
|
|
121
138
|
value={identifier}
|
|
@@ -123,7 +140,6 @@ function IdentifierStepRaw() {
|
|
|
123
140
|
placeholder={content.placeholder.email}
|
|
124
141
|
disabled={isLoading}
|
|
125
142
|
required
|
|
126
|
-
autoFocus
|
|
127
143
|
autoComplete="email"
|
|
128
144
|
inputMode="email"
|
|
129
145
|
autoCapitalize="off"
|
|
@@ -162,20 +178,6 @@ function IdentifierStepRaw() {
|
|
|
162
178
|
<AuthConsent termsUrl={termsUrl} privacyUrl={privacyUrl} />
|
|
163
179
|
</form>
|
|
164
180
|
|
|
165
|
-
{enableGithubAuth && (
|
|
166
|
-
<>
|
|
167
|
-
<AuthDivider />
|
|
168
|
-
<AuthButton
|
|
169
|
-
type="button"
|
|
170
|
-
variant="secondary"
|
|
171
|
-
loading={isGithubLoading}
|
|
172
|
-
onClick={startGithubAuth}
|
|
173
|
-
>
|
|
174
|
-
<Github className="w-5 h-5" />
|
|
175
|
-
{content.oauth.github}
|
|
176
|
-
</AuthButton>
|
|
177
|
-
</>
|
|
178
|
-
)}
|
|
179
181
|
</AuthContainer>
|
|
180
182
|
);
|
|
181
183
|
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useContext, useMemo } from 'react';
|
|
4
4
|
|
|
5
|
-
import { useAuthForm } from '@djangocfg/api/auth';
|
|
5
|
+
import { useAuthForm, useOAuthProviders } from '@djangocfg/api/auth';
|
|
6
6
|
|
|
7
|
+
import { resolveOAuthProviders } from './components/oauth/oauthProviders';
|
|
7
8
|
import type { AuthFormContextType, AuthLayoutProps } from './types';
|
|
8
9
|
|
|
9
10
|
// Exported so test/mock providers (storybook) can supply their own value
|
|
@@ -24,7 +25,7 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
24
25
|
supportUrl,
|
|
25
26
|
termsUrl,
|
|
26
27
|
privacyUrl,
|
|
27
|
-
|
|
28
|
+
oauthProviders,
|
|
28
29
|
logo,
|
|
29
30
|
redirectUrl,
|
|
30
31
|
marketingConsent,
|
|
@@ -37,6 +38,16 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
37
38
|
// Require terms acceptance only if terms or privacy URL is provided
|
|
38
39
|
const requireTermsAcceptance = Boolean(termsUrl || privacyUrl);
|
|
39
40
|
|
|
41
|
+
// The server's provider list, narrowed by the optional `oauthProviders` prop.
|
|
42
|
+
const { providers: serverProviders, isReady: oauthReady } = useOAuthProviders();
|
|
43
|
+
const oauth = useMemo(
|
|
44
|
+
() => ({
|
|
45
|
+
providers: resolveOAuthProviders(serverProviders.map((p) => p.id), oauthProviders),
|
|
46
|
+
isReady: oauthReady,
|
|
47
|
+
}),
|
|
48
|
+
[serverProviders, oauthProviders, oauthReady],
|
|
49
|
+
);
|
|
50
|
+
|
|
40
51
|
// Use the auth form hook
|
|
41
52
|
|
|
42
53
|
const authForm = useAuthForm({
|
|
@@ -60,7 +71,7 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
60
71
|
supportUrl,
|
|
61
72
|
termsUrl,
|
|
62
73
|
privacyUrl,
|
|
63
|
-
|
|
74
|
+
oauth,
|
|
64
75
|
logo,
|
|
65
76
|
redirectUrl,
|
|
66
77
|
// Renamed: `marketingConsent` (the name) is the checkbox STATE from the form hook.
|
|
@@ -72,7 +83,7 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
|
|
|
72
83
|
supportUrl,
|
|
73
84
|
termsUrl,
|
|
74
85
|
privacyUrl,
|
|
75
|
-
|
|
86
|
+
oauth,
|
|
76
87
|
logo,
|
|
77
88
|
redirectUrl,
|
|
78
89
|
marketingConsent,
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
--spring-snappy: cubic-bezier(0.2, 0, 0, 1);
|
|
14
14
|
--auth-radius: 0.75rem;
|
|
15
15
|
--auth-radius-sm: 0.5rem;
|
|
16
|
+
/* Secondary text of the sign-in page. The theme's `--muted-foreground` is
|
|
17
|
+
4.2:1 on the light surface (AA needs 4.5:1; axe rejects it), so the page
|
|
18
|
+
pulls it 18% toward `--foreground`: darker in light, lighter in dark. */
|
|
19
|
+
--auth-muted: color-mix(in oklab, var(--muted-foreground) 82%, var(--foreground));
|
|
16
20
|
--auth-radius-xs: 0.375rem;
|
|
17
21
|
/* The single vertical step of the auth column. */
|
|
18
22
|
--auth-gap: 1rem;
|
|
@@ -59,7 +63,7 @@
|
|
|
59
63
|
padding-inline: 0.5rem 0.75rem;
|
|
60
64
|
border-radius: var(--auth-radius-sm);
|
|
61
65
|
font-size: 0.875rem;
|
|
62
|
-
color: var(--muted
|
|
66
|
+
color: var(--auth-muted);
|
|
63
67
|
transition: color 0.15s var(--spring-snappy);
|
|
64
68
|
}
|
|
65
69
|
.auth-topbar__home:hover {
|
|
@@ -92,7 +96,7 @@
|
|
|
92
96
|
height: 2.75rem;
|
|
93
97
|
padding-inline: 0.75rem;
|
|
94
98
|
font-size: 0.75rem;
|
|
95
|
-
color: var(--muted
|
|
99
|
+
color: var(--auth-muted);
|
|
96
100
|
transition: color 0.15s var(--spring-snappy);
|
|
97
101
|
}
|
|
98
102
|
.auth-locale-trigger:hover,
|
|
@@ -199,7 +203,7 @@ a.auth-logo:focus-visible {
|
|
|
199
203
|
.auth-subtitle {
|
|
200
204
|
font-size: 0.9375rem;
|
|
201
205
|
line-height: 1.5;
|
|
202
|
-
color: var(--muted
|
|
206
|
+
color: var(--auth-muted);
|
|
203
207
|
margin: 0.125rem 0 0;
|
|
204
208
|
}
|
|
205
209
|
|
|
@@ -315,7 +319,7 @@ a.auth-logo:focus-visible {
|
|
|
315
319
|
the old `opacity: .45` produced (it still looked clickable). */
|
|
316
320
|
.auth-button-primary:disabled {
|
|
317
321
|
background: var(--muted);
|
|
318
|
-
color: var(--muted
|
|
322
|
+
color: var(--auth-muted);
|
|
319
323
|
}
|
|
320
324
|
|
|
321
325
|
/* GitHub / OAuth: a quiet outline button so the email path (primary) stays
|
|
@@ -340,6 +344,43 @@ a.auth-logo:focus-visible {
|
|
|
340
344
|
opacity: 0.88;
|
|
341
345
|
}
|
|
342
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
|
+
|
|
343
384
|
/* Loading state */
|
|
344
385
|
.auth-button-loading {
|
|
345
386
|
color: transparent !important;
|
|
@@ -347,6 +388,10 @@ a.auth-logo:focus-visible {
|
|
|
347
388
|
pointer-events: none;
|
|
348
389
|
}
|
|
349
390
|
|
|
391
|
+
.auth-button-loading > .auth-oauth-icon {
|
|
392
|
+
opacity: 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
350
395
|
.auth-button-loading::after {
|
|
351
396
|
content: "";
|
|
352
397
|
position: absolute;
|
|
@@ -358,6 +403,11 @@ a.auth-logo:focus-visible {
|
|
|
358
403
|
animation: authSpin 0.55s linear infinite;
|
|
359
404
|
}
|
|
360
405
|
|
|
406
|
+
.auth-button-secondary.auth-button-loading::after {
|
|
407
|
+
border-color: var(--foreground);
|
|
408
|
+
border-right-color: transparent;
|
|
409
|
+
}
|
|
410
|
+
|
|
361
411
|
.auth-button-primary.auth-button-loading::after {
|
|
362
412
|
border-color: color-mix(in oklab, var(--primary-foreground) 70%, transparent);
|
|
363
413
|
border-right-color: transparent;
|
|
@@ -367,13 +417,39 @@ a.auth-logo:focus-visible {
|
|
|
367
417
|
to { transform: rotate(360deg); }
|
|
368
418
|
}
|
|
369
419
|
|
|
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
|
+
|
|
370
446
|
/* ===== DIVIDER ===== */
|
|
371
447
|
|
|
372
448
|
.auth-divider {
|
|
373
449
|
display: flex;
|
|
374
450
|
align-items: center;
|
|
375
451
|
gap: 0.75rem;
|
|
376
|
-
color: var(--muted
|
|
452
|
+
color: var(--auth-muted);
|
|
377
453
|
font-size: 0.75rem;
|
|
378
454
|
letter-spacing: 0.02em;
|
|
379
455
|
text-transform: uppercase;
|
|
@@ -460,7 +536,7 @@ a.auth-logo:focus-visible {
|
|
|
460
536
|
justify-content: center;
|
|
461
537
|
gap: 0.75rem;
|
|
462
538
|
font-size: 0.75rem;
|
|
463
|
-
color: var(--muted
|
|
539
|
+
color: var(--auth-muted);
|
|
464
540
|
}
|
|
465
541
|
|
|
466
542
|
.auth-footer a {
|
|
@@ -495,11 +571,11 @@ a.auth-logo:focus-visible {
|
|
|
495
571
|
text-align: center;
|
|
496
572
|
font-size: 0.6875rem;
|
|
497
573
|
line-height: 1.5;
|
|
498
|
-
color: var(--muted
|
|
574
|
+
color: var(--auth-muted);
|
|
499
575
|
}
|
|
500
576
|
|
|
501
577
|
.auth-consent-link {
|
|
502
|
-
color: var(--muted
|
|
578
|
+
color: var(--auth-muted);
|
|
503
579
|
text-decoration: underline;
|
|
504
580
|
text-underline-offset: 2px;
|
|
505
581
|
text-decoration-color: color-mix(in oklab, var(--muted-foreground) 45%, transparent);
|
|
@@ -527,7 +603,7 @@ a.auth-logo:focus-visible {
|
|
|
527
603
|
text-align: left;
|
|
528
604
|
font-size: 0.75rem;
|
|
529
605
|
line-height: 1.5;
|
|
530
|
-
color: var(--muted
|
|
606
|
+
color: var(--auth-muted);
|
|
531
607
|
cursor: pointer;
|
|
532
608
|
user-select: none;
|
|
533
609
|
}
|
|
@@ -562,7 +638,7 @@ a.auth-logo:focus-visible {
|
|
|
562
638
|
font-size: 0.8125rem;
|
|
563
639
|
font-weight: 500;
|
|
564
640
|
letter-spacing: -0.005em;
|
|
565
|
-
color: var(--muted
|
|
641
|
+
color: var(--auth-muted);
|
|
566
642
|
background: transparent;
|
|
567
643
|
border: none;
|
|
568
644
|
border-radius: calc(var(--auth-radius-sm) - 2px);
|
|
@@ -692,7 +768,7 @@ a.auth-logo:focus-visible {
|
|
|
692
768
|
.auth-success-hint {
|
|
693
769
|
margin: 0;
|
|
694
770
|
font-size: 0.8125rem;
|
|
695
|
-
color: var(--muted
|
|
771
|
+
color: var(--auth-muted);
|
|
696
772
|
}
|
|
697
773
|
|
|
698
774
|
/* ===== DEV NOTICE ===== */
|