@djangocfg/layouts 2.1.599 → 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.
@@ -18,7 +18,7 @@ export type {
18
18
  UseAuthFormOptions,
19
19
  } from '@djangocfg/api/auth';
20
20
 
21
- import type { AuthFormReturn } from '@djangocfg/api/auth';
21
+ import type { AuthFormReturn, OAuthProviderId } from '@djangocfg/api/auth';
22
22
 
23
23
  import type { AuthBackgroundConfig, AuthShellVariant } from './shells';
24
24
 
@@ -59,7 +59,13 @@ export interface AuthLayoutConfig {
59
59
  privacyUrl?: string;
60
60
  /** Source URL for tracking */
61
61
  sourceUrl: string;
62
- /** Enable GitHub OAuth button */
62
+ /**
63
+ * Narrow or reorder the provider buttons. Leave it out: the sign-in page
64
+ * shows every provider the server has configured, in the server's order.
65
+ * Ids the server did not list are never shown.
66
+ */
67
+ oauthProviders?: OAuthProviderId[];
68
+ /** @deprecated The server decides which providers appear. Remove the prop; it has no effect and goes in the next minor. */
63
69
  enableGithubAuth?: boolean;
64
70
  /** Logo node rendered above the form and on the success screen. Pass any
65
71
  * React node — an inline-SVG brand component, an `<img>`, etc. */
@@ -77,8 +83,16 @@ export interface AuthLayoutConfig {
77
83
  // `marketingConsent` is omitted from the config side of the merge: the name is
78
84
  // taken by the checkbox *state* (boolean | null) from AuthFormReturn, so the
79
85
  // prop object is re-exposed as `marketingConsentConfig`.
80
- export interface AuthFormContextType extends AuthFormReturn, Omit<AuthLayoutConfig, 'marketingConsent'> {
86
+ export interface AuthFormContextType
87
+ extends AuthFormReturn,
88
+ Omit<AuthLayoutConfig, 'marketingConsent' | 'oauthProviders' | 'enableGithubAuth'> {
81
89
  marketingConsentConfig?: AuthMarketingConsentConfig;
90
+ /** The provider buttons to show: the server's list after the `oauthProviders` prop is applied. */
91
+ oauth: {
92
+ providers: OAuthProviderId[];
93
+ /** False only while the server's list is first loading. */
94
+ isReady: boolean;
95
+ };
82
96
  }
83
97
 
84
98
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1,13 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useMemo, useRef, useState } from 'react';
4
+ import { SWRConfig } from 'swr';
4
5
 
5
- import type {
6
- AuthStep,
7
- WebmailLink,
6
+ import {
7
+ type AuthStep,
8
+ type OAuthProviderId,
9
+ useOAuthProviders,
10
+ type WebmailLink,
8
11
  } from '@djangocfg/api/auth';
9
12
 
10
13
  import { AuthFormContext } from '../layouts/AuthLayout/context';
14
+ import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/oauthProviders';
11
15
  import type { AuthFormContextType, AuthLayoutConfig } from '../layouts/AuthLayout/types';
12
16
 
13
17
  // Storybook-only provider. Reproduces the AuthFormContext shape with
@@ -27,8 +31,20 @@ export interface MockAuthFormProviderProps extends Partial<AuthLayoutConfig> {
27
31
  rateLimitSeconds?: number;
28
32
  resendSeconds?: number;
29
33
  webmail?: WebmailLink | null;
34
+
35
+ /** Simulates the server's list still loading: the provider block shows its reserved space. */
36
+ oauthPending?: boolean;
37
+
38
+ /**
39
+ * Read the provider list through the real `useOAuthProviders` (`GET /oauth/providers/`),
40
+ * as `AuthLayout` does; `oauthProviders` then only narrows it. Pair it with
41
+ * `oauthApiMock` so the request is answered. Off: `oauthProviders` is the list.
42
+ */
43
+ oauthFromServer?: boolean;
30
44
  }
31
45
 
46
+ const NO_PROVIDERS: OAuthProviderId[] = [];
47
+
32
48
  function formatRateLimit(seconds: number): string {
33
49
  if (seconds <= 0) return '';
34
50
  if (seconds >= 60) {
@@ -39,7 +55,26 @@ function formatRateLimit(seconds: number): string {
39
55
  return `${seconds}s`;
40
56
  }
41
57
 
42
- export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
58
+ /** Fresh SWR cache per story: the list is cached for an hour, which would leak one story's providers into the next. */
59
+ const freshSwrCache = () => new Map();
60
+
61
+ function WithServerOAuth({ oauthProviders, oauthFromServer: _, ...rest }: MockAuthFormProviderProps) {
62
+ const { providers, isReady } = useOAuthProviders();
63
+ const ids = useMemo(() => providers.map((p) => p.id), [providers]);
64
+ const resolved = useMemo(() => resolveOAuthProviders(ids, oauthProviders), [ids, oauthProviders]);
65
+ return <MockAuthFormProviderBase {...rest} oauthProviders={resolved} oauthPending={!isReady} />;
66
+ }
67
+
68
+ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = (props) =>
69
+ props.oauthFromServer ? (
70
+ <SWRConfig value={{ provider: freshSwrCache }}>
71
+ <WithServerOAuth {...props} />
72
+ </SWRConfig>
73
+ ) : (
74
+ <MockAuthFormProviderBase {...props} />
75
+ );
76
+
77
+ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
43
78
  children,
44
79
 
45
80
  // Layout config
@@ -47,7 +82,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
47
82
  supportUrl,
48
83
  termsUrl,
49
84
  privacyUrl,
50
- enableGithubAuth = false,
85
+ oauthProviders = NO_PROVIDERS,
51
86
  logo,
52
87
  redirectUrl = '/dashboard',
53
88
  marketingConsent: marketingConsentConfig,
@@ -62,6 +97,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
62
97
  rateLimitSeconds: initialRateLimitSeconds = 0,
63
98
  resendSeconds: initialResendSeconds = 0,
64
99
  webmail: initialWebmail = null,
100
+ oauthPending = false,
65
101
  }) => {
66
102
  const [step, setStep] = useState<AuthStep>(initialStep);
67
103
  const [identifier, setIdentifier] = useState(initialIdentifier);
@@ -75,6 +111,11 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
75
111
  const [resendSeconds, setResendSeconds] = useState(initialResendSeconds);
76
112
  const [webmail, setWebmail] = useState<WebmailLink | null>(initialWebmail);
77
113
 
114
+ const oauth = useMemo(
115
+ () => ({ providers: oauthProviders, isReady: !oauthPending }),
116
+ [oauthProviders, oauthPending],
117
+ );
118
+
78
119
  const isAutoSubmittingFromUrl = useRef(false);
79
120
 
80
121
  const value: AuthFormContextType = useMemo(() => {
@@ -135,7 +176,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
135
176
  supportUrl,
136
177
  termsUrl,
137
178
  privacyUrl,
138
- enableGithubAuth,
179
+ oauth,
139
180
  logo,
140
181
  redirectUrl,
141
182
  marketingConsentConfig,
@@ -155,7 +196,7 @@ export const MockAuthFormProvider: React.FC<MockAuthFormProviderProps> = ({
155
196
  supportUrl,
156
197
  termsUrl,
157
198
  privacyUrl,
158
- enableGithubAuth,
199
+ oauth,
159
200
  logo,
160
201
  redirectUrl,
161
202
  marketingConsentConfig,
@@ -32,7 +32,26 @@ export {
32
32
  IdentifierStep,
33
33
  OTPStep,
34
34
  } from '../layouts/AuthLayout/components/steps';
35
+ // The provider round-trip card; reads `?provider=...` from the (mocked) router.
36
+ export { OAuthCallback } from '../layouts/AuthLayout/components/oauth';
35
37
 
36
38
  // The 2FA challenge is its own surface; stories open it by beginning a challenge.
37
39
  export { TwoFactorChallengeDialog, TwoFactorSetup } from '../snippets/TwoFactor';
38
40
  export { beginTwoFactorChallenge, endTwoFactorChallenge } from '@djangocfg/api/auth';
41
+
42
+ // The one fake of the OAuth network for stories (providers, authorize, callback).
43
+ export {
44
+ OAUTH_SERVER_ERROR_CODES,
45
+ getOAuthApiMock,
46
+ mockOAuthState,
47
+ oauthApiMock,
48
+ seedOAuthBrowser,
49
+ type OAuthApiCall,
50
+ type OAuthApiMock,
51
+ type OAuthApiScript,
52
+ type OAuthApiSeed,
53
+ type OAuthServerErrorCode,
54
+ } from './mockOAuthApi';
55
+
56
+ // The whole layout, for stories that run a real round trip (callback to success screen).
57
+ export { AuthLayout } from '../layouts/AuthLayout/AuthLayout';
@@ -0,0 +1,230 @@
1
+ // Storybook / test-only. The one place that fakes the OAuth network for stories:
2
+ // `GET /oauth/providers/`, `POST /oauth/<provider>/authorize/` and
3
+ // `POST /oauth/<provider>/callback/`, answered at the `fetch` level so the real
4
+ // generated client, `useOAuthProviders` and `useOAuthLogin` all run unchanged.
5
+ // Any other request is refused, so no story can reach a real backend.
6
+
7
+ import { CFG_KEYS, cfgStorage } from '@djangocfg/api/storage';
8
+ import type { OAuthFailureCode, OAuthProviderId } from '@djangocfg/api/auth';
9
+
10
+ /** Codes the backend returns in `{ error }` (the closed set of the OpenAPI enum). */
11
+ export type OAuthServerErrorCode = Exclude<OAuthFailureCode, 'cancelled' | 'unknown_provider'>;
12
+
13
+ /** HTTP status per code, from the backend contract table. Exhaustive: a new code fails typecheck. */
14
+ const STATUS: Record<OAuthServerErrorCode, number> = {
15
+ oauth_disabled: 503,
16
+ github_oauth_disabled: 503,
17
+ origin_not_allowed: 400,
18
+ missing_redirect_uri: 400,
19
+ invalid_state: 400,
20
+ oauth_exchange_failed: 400,
21
+ invalid_id_token: 400,
22
+ email_missing: 400,
23
+ email_not_verified: 400,
24
+ hosted_domain_not_allowed: 403,
25
+ account_inactive: 403,
26
+ link_not_allowed: 403,
27
+ rate_limited: 429,
28
+ provider_unavailable: 502,
29
+ server_error: 500,
30
+ not_found: 404,
31
+ };
32
+
33
+ /** Every code the server can answer with, for parametrised stories. */
34
+ export const OAUTH_SERVER_ERROR_CODES = Object.keys(STATUS) as OAuthServerErrorCode[];
35
+
36
+ export interface OAuthApiScript {
37
+ /** The enabled providers in display order; `pending` never answers, `unavailable` fails the request. */
38
+ providers?: readonly OAuthProviderId[] | 'pending' | 'unavailable';
39
+ /** `ok` returns the provider URL and a state; `pending` never answers; a code answers with that error. */
40
+ authorize?: 'ok' | 'pending' | OAuthServerErrorCode;
41
+ /** `ok` returns a session; `pending` never answers; a code answers with that error. */
42
+ callback?: 'ok' | 'pending' | OAuthServerErrorCode;
43
+ isNewUser?: boolean;
44
+ }
45
+
46
+ export interface OAuthApiCall {
47
+ method: string;
48
+ path: string;
49
+ provider?: OAuthProviderId;
50
+ body?: unknown;
51
+ }
52
+
53
+ export interface OAuthApiMock {
54
+ /** Every request the page made, in order. */
55
+ readonly calls: readonly OAuthApiCall[];
56
+ /** Requests nothing here answers; always empty in a healthy story. */
57
+ readonly unexpected: readonly string[];
58
+ /** Full-page navigations the page attempted (cancelled, so the story stays put). */
59
+ readonly navigations: readonly string[];
60
+ /** Change the answers mid-story. */
61
+ set(patch: OAuthApiScript): void;
62
+ }
63
+
64
+ export interface OAuthApiSeed {
65
+ /** The `state` this browser stored when it started a flow (what a returning callback is checked against). */
66
+ state?: string;
67
+ provider?: OAuthProviderId;
68
+ /** The back-url a route guard saved before sending the visitor to sign in. */
69
+ savedRedirect?: string;
70
+ }
71
+
72
+ const PROVIDER_NAMES: Record<OAuthProviderId, string> = { github: 'GitHub', google: 'Google' };
73
+
74
+ const PROVIDER_URLS: Record<OAuthProviderId, string> = {
75
+ github: 'https://github.com/login/oauth/authorize',
76
+ google: 'https://accounts.google.com/o/oauth2/v2/auth',
77
+ };
78
+
79
+ /** The state the mocked `authorize` hands out. */
80
+ export const mockOAuthState = (provider: OAuthProviderId): string => `mock-state-${provider}`;
81
+
82
+ const ROUTE = /\/oauth\/(?:(providers)|(github|google)\/(authorize|callback))\/?$/;
83
+
84
+ const json = (status: number, body: unknown): Response =>
85
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
86
+
87
+ const failure = (code: OAuthServerErrorCode): Response =>
88
+ json(STATUS[code], { error: code, error_description: `Mock server answered ${code}.` });
89
+
90
+ const NEVER = new Promise<Response>(() => {});
91
+
92
+ let active: OAuthApiMock | null = null;
93
+
94
+ /** The mock a story's `beforeEach` installed. */
95
+ export const getOAuthApiMock = (): OAuthApiMock => {
96
+ if (!active) throw new Error('mockOAuthApi: no mock is installed; add `beforeEach: oauthApiMock({...})` to the story.');
97
+ return active;
98
+ };
99
+
100
+ /**
101
+ * Write what a returning visitor would have in this browser.
102
+ * Safe to call again (a remounted story starts from a known storage).
103
+ */
104
+ export const seedOAuthBrowser = ({ state, provider, savedRedirect }: OAuthApiSeed): void => {
105
+ cfgStorage.remove(CFG_KEYS.oauthState.key);
106
+ cfgStorage.remove(CFG_KEYS.oauthProvider.key);
107
+ if (state) cfgStorage.write(CFG_KEYS.oauthState.key, state);
108
+ if (provider) cfgStorage.write(CFG_KEYS.oauthProvider.key, provider);
109
+ if (savedRedirect !== undefined) {
110
+ // The redirect manager stores its value JSON-encoded.
111
+ window.sessionStorage.setItem(CFG_KEYS.redirectUrl.key, JSON.stringify(savedRedirect));
112
+ }
113
+ };
114
+
115
+ interface NavigationEventLike extends Event {
116
+ destination: { url: string; sameDocument: boolean };
117
+ }
118
+ interface NavigationLike {
119
+ addEventListener(type: 'navigate', listener: (event: NavigationEventLike) => void): void;
120
+ removeEventListener(type: 'navigate', listener: (event: NavigationEventLike) => void): void;
121
+ }
122
+
123
+ /**
124
+ * Install the fake OAuth network for one story and capture full-page
125
+ * navigations (the redirect to the provider, the redirect after sign-in) so the
126
+ * iframe is never navigated away. Returns the story cleanup.
127
+ */
128
+ const install = (initial: OAuthApiScript): (() => void) => {
129
+ const script: OAuthApiScript = { providers: [], authorize: 'ok', callback: 'ok', ...initial };
130
+ const calls: OAuthApiCall[] = [];
131
+ const unexpected: string[] = [];
132
+ const navigations: string[] = [];
133
+
134
+ const mock: OAuthApiMock = {
135
+ calls,
136
+ unexpected,
137
+ navigations,
138
+ set: (patch) => {
139
+ Object.assign(script, patch);
140
+ },
141
+ };
142
+
143
+ const originalFetch = globalThis.fetch;
144
+ globalThis.fetch = async (input, init) => {
145
+ const request = new Request(input, init);
146
+ const path = new URL(request.url).pathname;
147
+ const match = ROUTE.exec(path);
148
+ if (!match) {
149
+ unexpected.push(`${request.method} ${request.url}`);
150
+ throw new TypeError(`mockOAuthApi: unexpected request ${request.method} ${request.url}`);
151
+ }
152
+
153
+ const [, providersRoute, providerId, action] = match;
154
+ const provider = providerId as OAuthProviderId | undefined;
155
+ const body = request.method === 'POST' ? await request.clone().json().catch(() => undefined) : undefined;
156
+ calls.push({ method: request.method, path, provider, body });
157
+
158
+ if (providersRoute) {
159
+ if (script.providers === 'pending') return NEVER;
160
+ if (script.providers === 'unavailable') return failure('server_error');
161
+ return json(200, {
162
+ providers: (script.providers ?? []).map((id) => ({ id, name: PROVIDER_NAMES[id], icon: id })),
163
+ });
164
+ }
165
+
166
+ if (action === 'authorize') {
167
+ if (script.authorize === 'pending') return NEVER;
168
+ if (script.authorize !== 'ok' && script.authorize) return failure(script.authorize);
169
+ const state = mockOAuthState(provider!);
170
+ return json(200, { authorization_url: `${PROVIDER_URLS[provider!]}?client_id=mock&state=${state}`, state });
171
+ }
172
+
173
+ if (script.callback === 'pending') return NEVER;
174
+ if (script.callback !== 'ok' && script.callback) return failure(script.callback);
175
+ return json(200, {
176
+ requires_2fa: false,
177
+ access: 'mock-access-token',
178
+ refresh: 'mock-refresh-token',
179
+ user: { id: 1, email: 'jane.cooper@example.com' },
180
+ is_new_user: script.isNewUser ?? false,
181
+ is_new_connection: false,
182
+ });
183
+ };
184
+
185
+ const navigation = (window as unknown as { navigation?: NavigationLike }).navigation;
186
+ const onNavigate = (event: NavigationEventLike) => {
187
+ // `replaceState` and hash changes stay in the document; only a real page change is held back.
188
+ if (event.destination.sameDocument) return;
189
+ navigations.push(event.destination.url);
190
+ if (event.cancelable) event.preventDefault();
191
+ };
192
+ navigation?.addEventListener('navigate', onNavigate);
193
+
194
+ // The auth hooks log every failure they handle (`api:auth` tag). A story that
195
+ // drives a failure on purpose would trip the console gate with the very line
196
+ // that proves the flow ran, so this one tag is dropped while the mock is on.
197
+ const restoreConsole = (['error', 'warn'] as const).map((method) => {
198
+ const previous = console[method];
199
+ console[method] = (...args: unknown[]) => {
200
+ if (typeof args[0] === 'string' && args[0].includes('api:auth')) return;
201
+ previous.apply(console, args);
202
+ };
203
+ return () => {
204
+ console[method] = previous;
205
+ };
206
+ });
207
+
208
+ active = mock;
209
+ return () => {
210
+ for (const restore of restoreConsole) restore();
211
+ navigation?.removeEventListener('navigate', onNavigate);
212
+ globalThis.fetch = originalFetch;
213
+ active = null;
214
+ };
215
+ };
216
+
217
+ /**
218
+ * A story `beforeEach`: fakes the OAuth endpoints with `script` and optionally
219
+ * seeds this browser's storage.
220
+ *
221
+ * ```ts
222
+ * beforeEach: oauthApiMock({ providers: ['google'] }),
223
+ * ```
224
+ */
225
+ export const oauthApiMock =
226
+ (script: OAuthApiScript = {}, seed?: OAuthApiSeed) =>
227
+ (): (() => void) => {
228
+ if (seed) seedOAuthBrowser(seed);
229
+ return install(script);
230
+ };