@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.
- 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
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
6
|
-
AuthStep,
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
+
oauth,
|
|
159
200
|
logo,
|
|
160
201
|
redirectUrl,
|
|
161
202
|
marketingConsentConfig,
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
+
};
|