@djangocfg/layouts 2.1.599 → 2.1.601

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.
@@ -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
- variant?: 'primary' | 'secondary';
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 = variant === 'primary' ? 'auth-button-primary' : 'auth-button-secondary';
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
- * - Passive consent line (continuing = consent; no opt-in checkbox)
28
- * - OAuth options
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
- enableGithubAuth,
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
- // GitHub OAuth
101
- const { isLoading: isGithubLoading, startGithubAuth } = useGithubAuth({
102
- sourceUrl,
103
- onError: setError,
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
- enableGithubAuth = false,
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
- enableGithubAuth,
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
- enableGithubAuth,
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-foreground);
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-foreground);
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-foreground);
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-foreground);
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-foreground);
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-foreground);
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-foreground);
574
+ color: var(--auth-muted);
499
575
  }
500
576
 
501
577
  .auth-consent-link {
502
- color: var(--muted-foreground);
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-foreground);
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-foreground);
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-foreground);
771
+ color: var(--auth-muted);
696
772
  }
697
773
 
698
774
  /* ===== DEV NOTICE ===== */