@djangocfg/api 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.
- package/README.md +2 -1
- package/dist/OAuthProvidersResponse-QZ566OYR.mjs +25 -0
- package/dist/OAuthProvidersResponse-QZ566OYR.mjs.map +1 -0
- package/dist/auth.cjs +526 -288
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +144 -31
- package/dist/auth.d.ts +144 -31
- package/dist/auth.mjs +409 -213
- package/dist/auth.mjs.map +1 -1
- package/dist/cfg_accounts_oauth_connections_response_200_AutoRef-N532BRPN.mjs +28 -0
- package/dist/cfg_accounts_oauth_connections_response_200_AutoRef-N532BRPN.mjs.map +1 -0
- package/dist/chunk-PCOSFQTH.mjs +10 -0
- package/dist/chunk-PCOSFQTH.mjs.map +1 -0
- package/dist/clients.cjs +34 -3
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +135 -14
- package/dist/clients.d.ts +135 -14
- package/dist/clients.mjs +34 -3
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +1 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +1 -0
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +34 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +169 -17
- package/dist/index.d.ts +169 -17
- package/dist/index.mjs +34 -3
- package/dist/index.mjs.map +1 -1
- package/dist/storage.cjs +1 -0
- package/dist/storage.cjs.map +1 -1
- package/dist/storage.d.cts +1 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.mjs +1 -0
- package/dist/storage.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/noStorageLiterals.test.ts +1 -1
- package/src/_api/generated/_cfg_accounts/hooks/index.ts +2 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthGoogleAuthorizeCreate.ts +68 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthGoogleCallbackCreate.ts +68 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +263 -19
- package/src/_api/generated/_cfg_accounts/schemas/OAuthCallbackRequestRequest.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OAuthError.ts +2 -1
- package/src/_api/generated/_cfg_accounts/schemas/OAuthErrorCodeEnum.ts +9 -0
- package/src/_api/generated/_cfg_accounts/schemas/OAuthProviderEnum.ts +1 -1
- package/src/_api/generated/_cfg_accounts/schemas/OAuthProviderInfo.ts +14 -0
- package/src/_api/generated/_cfg_accounts/schemas/OAuthProvidersResponse.ts +2 -1
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
- package/src/_api/generated/helpers/keys.ts +1 -0
- package/src/_api/generated/openapi.json +263 -19
- package/src/_api/generated/sdk.gen.ts +36 -4
- package/src/_api/generated/types.gen.ts +136 -11
- package/src/auth/__tests__/oauthErrors.test.ts +23 -0
- package/src/auth/__tests__/passwordLogin.test.tsx +1 -0
- package/src/auth/__tests__/useOAuthLogin.test.tsx +387 -0
- package/src/auth/hooks/index.ts +12 -1
- package/src/auth/hooks/useGithubAuth.ts +15 -225
- package/src/auth/hooks/useOAuthLogin.ts +237 -0
- package/src/auth/hooks/useOAuthProviders.ts +76 -0
- package/src/auth/utils/index.ts +8 -0
- package/src/auth/utils/oauthErrors.ts +45 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { OAuthErrorCodeEnum } from '../../_api/generated/types.gen';
|
|
4
|
+
import { readOAuthFailure } from '../utils/oauthErrors';
|
|
5
|
+
|
|
6
|
+
const failure = (error: string) => readOAuthFailure({ response: { error, error_description: 'prose' } }, 'fallback');
|
|
7
|
+
|
|
8
|
+
describe('readOAuthFailure', () => {
|
|
9
|
+
it.each(Object.values(OAuthErrorCodeEnum))('recognises the generated code %s', (code) => {
|
|
10
|
+
expect(failure(code)).toEqual({ code, message: 'prose' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('does not treat an unknown error string as a code', () => {
|
|
14
|
+
expect(failure('teapot').code).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('falls back when the body has no description', () => {
|
|
18
|
+
expect(readOAuthFailure({ response: { error: 'invalid_state' } }, 'fallback')).toEqual({
|
|
19
|
+
code: 'invalid_state',
|
|
20
|
+
message: 'fallback',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth sign-in contract: one hook, every provider.
|
|
3
|
+
*
|
|
4
|
+
* What these guard: the state check is mandatory (a callback with no stored
|
|
5
|
+
* state is a login-CSRF attempt, not a legacy flow), the state is single use,
|
|
6
|
+
* `redirect_uri` is never sent, the session is written through the one store,
|
|
7
|
+
* and the deprecated GitHub wrapper keeps its shape.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// @vitest-environment jsdom
|
|
11
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { SWRConfig } from 'swr';
|
|
14
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import { APIError } from '../../_api/generated/helpers';
|
|
17
|
+
|
|
18
|
+
const hardPush = vi.fn();
|
|
19
|
+
vi.mock('../hooks/useCfgRouter', () => ({
|
|
20
|
+
useCfgRouter: () => ({ push: vi.fn(), replace: vi.fn(), hardPush, hardReplace: vi.fn() }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const tokens: { access: string | null; refresh: string | null } = { access: null, refresh: null };
|
|
24
|
+
const { clearSession } = vi.hoisted(() => ({ clearSession: vi.fn() }));
|
|
25
|
+
vi.mock('../../_api/generated/helpers/auth', () => ({
|
|
26
|
+
installAuthOnClient: () => {},
|
|
27
|
+
auth: {
|
|
28
|
+
setSession: ({ access, refresh }: { access: string; refresh?: string | null }) => {
|
|
29
|
+
tokens.access = access;
|
|
30
|
+
tokens.refresh = refresh ?? null;
|
|
31
|
+
},
|
|
32
|
+
clearSession,
|
|
33
|
+
setTimezone: () => {},
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('../utils/logger', () => ({
|
|
38
|
+
authLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const sdk = {
|
|
42
|
+
cfgAccountsOauthGithubAuthorizeCreate: vi.fn(),
|
|
43
|
+
cfgAccountsOauthGithubCallbackCreate: vi.fn(),
|
|
44
|
+
cfgAccountsOauthGoogleAuthorizeCreate: vi.fn(),
|
|
45
|
+
cfgAccountsOauthGoogleCallbackCreate: vi.fn(),
|
|
46
|
+
cfgAccountsOauthProvidersRetrieve: vi.fn(),
|
|
47
|
+
};
|
|
48
|
+
vi.mock('../../_api/generated/sdk.gen', () => ({ CfgAccountsOauth: sdk }));
|
|
49
|
+
|
|
50
|
+
const STATE_KEY = 'cfg.auth.oauth_state';
|
|
51
|
+
const PROVIDER_KEY = 'cfg.auth.oauth_provider';
|
|
52
|
+
const REDIRECT_KEY = 'cfg.auth.redirect_url';
|
|
53
|
+
const USER = { id: 7, email: 'jane@example.com' };
|
|
54
|
+
const SESSION = { requires_2fa: false, access: 'a1', refresh: 'r1', user: USER, is_new_user: true };
|
|
55
|
+
|
|
56
|
+
async function loadHook(options = {}) {
|
|
57
|
+
const { useOAuthLogin } = await import('../hooks/useOAuthLogin');
|
|
58
|
+
return renderHook(() => useOAuthLogin(options));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('useOAuthLogin', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
tokens.access = null;
|
|
65
|
+
tokens.refresh = null;
|
|
66
|
+
sessionStorage.clear();
|
|
67
|
+
clearSession.mockImplementation(() => {
|
|
68
|
+
tokens.access = null;
|
|
69
|
+
tokens.refresh = null;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('start', () => {
|
|
74
|
+
it('clears the old session, stores state and provider, and sends no redirect_uri', async () => {
|
|
75
|
+
sdk.cfgAccountsOauthGoogleAuthorizeCreate.mockResolvedValue({
|
|
76
|
+
data: { authorization_url: 'about:blank#go', state: 'st-1' },
|
|
77
|
+
});
|
|
78
|
+
const { result } = await loadHook({ sourceUrl: 'https://app.example.com' });
|
|
79
|
+
|
|
80
|
+
await act(async () => {
|
|
81
|
+
await result.current.start('google');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(clearSession).toHaveBeenCalled();
|
|
85
|
+
expect(sdk.cfgAccountsOauthGoogleAuthorizeCreate).toHaveBeenCalledWith({
|
|
86
|
+
body: { source_url: 'https://app.example.com' },
|
|
87
|
+
throwOnError: true,
|
|
88
|
+
});
|
|
89
|
+
expect(sdk.cfgAccountsOauthGithubAuthorizeCreate).not.toHaveBeenCalled();
|
|
90
|
+
expect(sessionStorage.getItem(STATE_KEY)).toBe('st-1');
|
|
91
|
+
expect(sessionStorage.getItem(PROVIDER_KEY)).toBe('google');
|
|
92
|
+
expect(result.current.loadingProvider).toBe('google');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reports a server code and stops loading when authorize fails', async () => {
|
|
96
|
+
sdk.cfgAccountsOauthGithubAuthorizeCreate.mockRejectedValue(
|
|
97
|
+
new APIError(
|
|
98
|
+
503,
|
|
99
|
+
'Unavailable',
|
|
100
|
+
{ error: 'oauth_disabled', error_description: 'Not configured' },
|
|
101
|
+
'/x',
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const onError = vi.fn();
|
|
105
|
+
const { result } = await loadHook({ onError });
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await result.current.start('github');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.current.loadingProvider).toBeNull();
|
|
112
|
+
expect(result.current.errorCode).toBe('oauth_disabled');
|
|
113
|
+
expect(result.current.error).toBe('Not configured');
|
|
114
|
+
expect(onError).toHaveBeenCalledWith('Not configured', 'oauth_disabled');
|
|
115
|
+
expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('complete', () => {
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
sessionStorage.setItem(STATE_KEY, 'st-1');
|
|
122
|
+
sessionStorage.setItem(PROVIDER_KEY, 'google');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('exchanges the code, writes the session, and hands over the user', async () => {
|
|
126
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockResolvedValue({ data: SESSION });
|
|
127
|
+
const onSuccess = vi.fn();
|
|
128
|
+
const { result } = await loadHook({ onSuccess, skipRedirect: true });
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(sdk.cfgAccountsOauthGoogleCallbackCreate).toHaveBeenCalledWith({
|
|
135
|
+
body: { code: 'code-1', state: 'st-1' },
|
|
136
|
+
throwOnError: true,
|
|
137
|
+
});
|
|
138
|
+
expect(tokens).toEqual({ access: 'a1', refresh: 'r1' });
|
|
139
|
+
expect(onSuccess).toHaveBeenCalledWith(USER, true, 'google');
|
|
140
|
+
expect(hardPush).not.toHaveBeenCalled();
|
|
141
|
+
expect(result.current.isLoading).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects a state that differs from the stored one without calling the server', async () => {
|
|
145
|
+
const onError = vi.fn();
|
|
146
|
+
const { result } = await loadHook({ onError });
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.complete('google', 'code-1', 'attacker-state');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(sdk.cfgAccountsOauthGoogleCallbackCreate).not.toHaveBeenCalled();
|
|
153
|
+
expect(result.current.errorCode).toBe('invalid_state');
|
|
154
|
+
expect(onError).toHaveBeenCalledWith(expect.any(String), 'invalid_state');
|
|
155
|
+
expect(tokens.access).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rejects a callback when no state was stored (login CSRF)', async () => {
|
|
159
|
+
sessionStorage.clear();
|
|
160
|
+
const { result } = await loadHook();
|
|
161
|
+
|
|
162
|
+
await act(async () => {
|
|
163
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(sdk.cfgAccountsOauthGoogleCallbackCreate).not.toHaveBeenCalled();
|
|
167
|
+
expect(result.current.errorCode).toBe('invalid_state');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects a state that was stored for another provider', async () => {
|
|
171
|
+
const { result } = await loadHook();
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
await result.current.complete('github', 'code-1', 'st-1');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(sdk.cfgAccountsOauthGithubCallbackCreate).not.toHaveBeenCalled();
|
|
178
|
+
expect(result.current.errorCode).toBe('invalid_state');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('spends the stored state: a second completion with it fails', async () => {
|
|
182
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockResolvedValue({ data: SESSION });
|
|
183
|
+
const { result } = await loadHook({ skipRedirect: true });
|
|
184
|
+
|
|
185
|
+
await act(async () => {
|
|
186
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
187
|
+
});
|
|
188
|
+
await act(async () => {
|
|
189
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(sdk.cfgAccountsOauthGoogleCallbackCreate).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(result.current.errorCode).toBe('invalid_state');
|
|
194
|
+
expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('spends the state even when the exchange fails', async () => {
|
|
198
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockRejectedValue(
|
|
199
|
+
new APIError(400, 'Bad', { error: 'email_not_verified', error_description: 'Unverified' }, '/x'),
|
|
200
|
+
);
|
|
201
|
+
const { result } = await loadHook();
|
|
202
|
+
|
|
203
|
+
await act(async () => {
|
|
204
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.current.errorCode).toBe('email_not_verified');
|
|
208
|
+
expect(sessionStorage.getItem(STATE_KEY)).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('takes the 2FA branch without writing a session', async () => {
|
|
212
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockResolvedValue({
|
|
213
|
+
data: { requires_2fa: true, session_id: 'sess-9', should_prompt_2fa: true },
|
|
214
|
+
});
|
|
215
|
+
const onRequires2FA = vi.fn();
|
|
216
|
+
const onSuccess = vi.fn();
|
|
217
|
+
const { result } = await loadHook({ onRequires2FA, onSuccess });
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(onRequires2FA).toHaveBeenCalledWith('sess-9', true);
|
|
224
|
+
expect(onSuccess).not.toHaveBeenCalled();
|
|
225
|
+
expect(tokens.access).toBeNull();
|
|
226
|
+
expect(hardPush).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('navigates to the saved return URL first, with its hash intact', async () => {
|
|
230
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockResolvedValue({ data: SESSION });
|
|
231
|
+
sessionStorage.setItem(REDIRECT_KEY, JSON.stringify('/x#settings/mcp?request=1'));
|
|
232
|
+
const { result } = await loadHook({ redirectUrl: '/home' });
|
|
233
|
+
|
|
234
|
+
await act(async () => {
|
|
235
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(hardPush).toHaveBeenCalledWith('/x#settings/mcp?request=1');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('falls back to the redirectUrl option when nothing was saved', async () => {
|
|
242
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockResolvedValue({ data: SESSION });
|
|
243
|
+
const { result } = await loadHook({ redirectUrl: '/home' });
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(hardPush).toHaveBeenCalledWith('/home');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('maps an unrecognised failure to a message with no code', async () => {
|
|
253
|
+
sdk.cfgAccountsOauthGoogleCallbackCreate.mockRejectedValue(new Error('boom'));
|
|
254
|
+
const { result } = await loadHook();
|
|
255
|
+
|
|
256
|
+
await act(async () => {
|
|
257
|
+
await result.current.complete('google', 'code-1', 'st-1');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result.current.errorCode).toBeNull();
|
|
261
|
+
expect(result.current.error).toBe('boom');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('useGithubAuth (deprecated wrapper)', () => {
|
|
267
|
+
beforeEach(() => {
|
|
268
|
+
vi.clearAllMocks();
|
|
269
|
+
sessionStorage.clear();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('keeps its return shape and runs the GitHub adapter', async () => {
|
|
273
|
+
const { useGithubAuth } = await import('../hooks/useGithubAuth');
|
|
274
|
+
sdk.cfgAccountsOauthGithubAuthorizeCreate.mockResolvedValue({
|
|
275
|
+
data: { authorization_url: 'about:blank#go', state: 'gh-1' },
|
|
276
|
+
});
|
|
277
|
+
sdk.cfgAccountsOauthGithubCallbackCreate.mockResolvedValue({ data: SESSION });
|
|
278
|
+
const { result } = renderHook(() => useGithubAuth({ skipRedirect: true }));
|
|
279
|
+
|
|
280
|
+
expect(Object.keys(result.current).sort()).toEqual(
|
|
281
|
+
['error', 'handleGithubCallback', 'isLoading', 'startGithubAuth'],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await act(async () => {
|
|
285
|
+
await result.current.startGithubAuth();
|
|
286
|
+
});
|
|
287
|
+
expect(sessionStorage.getItem(PROVIDER_KEY)).toBe('github');
|
|
288
|
+
|
|
289
|
+
await act(async () => {
|
|
290
|
+
await result.current.handleGithubCallback('code-1', 'gh-1');
|
|
291
|
+
});
|
|
292
|
+
expect(sdk.cfgAccountsOauthGithubCallbackCreate).toHaveBeenCalledWith({
|
|
293
|
+
body: { code: 'code-1', state: 'gh-1' },
|
|
294
|
+
throwOnError: true,
|
|
295
|
+
});
|
|
296
|
+
expect(tokens.access).toBe('a1');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('useOAuthProviders', () => {
|
|
301
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
302
|
+
<SWRConfig value={{ provider: () => new Map() }}>{children}</SWRConfig>
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
beforeEach(() => {
|
|
306
|
+
vi.clearAllMocks();
|
|
307
|
+
sessionStorage.clear();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('returns the configured providers in server order', async () => {
|
|
311
|
+
sdk.cfgAccountsOauthProvidersRetrieve.mockResolvedValue({
|
|
312
|
+
data: {
|
|
313
|
+
providers: [
|
|
314
|
+
{ id: 'github', name: 'GitHub', icon: 'github' },
|
|
315
|
+
{ id: 'google', name: 'Google', icon: 'google' },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const { useOAuthProviders } = await import('../hooks/useOAuthProviders');
|
|
320
|
+
const { result } = renderHook(() => useOAuthProviders(), { wrapper });
|
|
321
|
+
|
|
322
|
+
expect(result.current.isReady).toBe(false);
|
|
323
|
+
await waitFor(() => expect(result.current.isReady).toBe(true));
|
|
324
|
+
expect(result.current.providers.map((p) => p.id)).toEqual(['github', 'google']);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('drops a provider this client has no adapter for', async () => {
|
|
328
|
+
sdk.cfgAccountsOauthProvidersRetrieve.mockResolvedValue({
|
|
329
|
+
data: { providers: [{ id: 'gitlab', name: 'GitLab', icon: 'gitlab' }, { id: 'google', name: 'Google', icon: 'google' }] },
|
|
330
|
+
});
|
|
331
|
+
const { useOAuthProviders } = await import('../hooks/useOAuthProviders');
|
|
332
|
+
const { result } = renderHook(() => useOAuthProviders(), { wrapper });
|
|
333
|
+
|
|
334
|
+
await waitFor(() => expect(result.current.isReady).toBe(true));
|
|
335
|
+
expect(result.current.providers.map((p) => p.id)).toEqual(['google']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns an empty list and is ready when the request fails', async () => {
|
|
339
|
+
sdk.cfgAccountsOauthProvidersRetrieve.mockRejectedValue(new Error('down'));
|
|
340
|
+
const { useOAuthProviders } = await import('../hooks/useOAuthProviders');
|
|
341
|
+
const { result } = renderHook(() => useOAuthProviders(), { wrapper });
|
|
342
|
+
|
|
343
|
+
await waitFor(() => expect(result.current.isReady).toBe(true));
|
|
344
|
+
expect(result.current.providers).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('persists the fetched list under the registry key', async () => {
|
|
348
|
+
sdk.cfgAccountsOauthProvidersRetrieve.mockResolvedValue({
|
|
349
|
+
data: { providers: [{ id: 'google', name: 'Google', icon: 'google' }] },
|
|
350
|
+
});
|
|
351
|
+
const { useOAuthProviders } = await import('../hooks/useOAuthProviders');
|
|
352
|
+
const { result } = renderHook(() => useOAuthProviders(), { wrapper });
|
|
353
|
+
|
|
354
|
+
await waitFor(() => expect(result.current.isReady).toBe(true));
|
|
355
|
+
expect(JSON.parse(sessionStorage.getItem('cfg.auth.oauth_providers') ?? 'null')).toEqual([
|
|
356
|
+
{ id: 'google', name: 'Google', icon: 'google' },
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('shows the cached list at once, then lets the server answer replace it', async () => {
|
|
361
|
+
sessionStorage.setItem(
|
|
362
|
+
'cfg.auth.oauth_providers',
|
|
363
|
+
JSON.stringify([{ id: 'github', name: 'GitHub', icon: 'github' }]),
|
|
364
|
+
);
|
|
365
|
+
sdk.cfgAccountsOauthProvidersRetrieve.mockResolvedValue({
|
|
366
|
+
data: { providers: [{ id: 'google', name: 'Google', icon: 'google' }] },
|
|
367
|
+
});
|
|
368
|
+
const { useOAuthProviders } = await import('../hooks/useOAuthProviders');
|
|
369
|
+
const { result } = renderHook(() => useOAuthProviders(), { wrapper });
|
|
370
|
+
|
|
371
|
+
expect(result.current.isReady).toBe(true);
|
|
372
|
+
expect(result.current.providers.map((p) => p.id)).toEqual(['github']);
|
|
373
|
+
await waitFor(() => expect(result.current.providers.map((p) => p.id)).toEqual(['google']));
|
|
374
|
+
expect(sessionStorage.getItem('cfg.auth.oauth_providers')).toContain('google');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('ignores an unreadable cache', async () => {
|
|
378
|
+
sessionStorage.setItem('cfg.auth.oauth_providers', '{not json');
|
|
379
|
+
sdk.cfgAccountsOauthProvidersRetrieve.mockResolvedValue({ data: { providers: [] } });
|
|
380
|
+
const { useOAuthProviders } = await import('../hooks/useOAuthProviders');
|
|
381
|
+
const { result } = renderHook(() => useOAuthProviders(), { wrapper });
|
|
382
|
+
|
|
383
|
+
expect(result.current.isReady).toBe(false);
|
|
384
|
+
await waitFor(() => expect(result.current.isReady).toBe(true));
|
|
385
|
+
expect(result.current.providers).toEqual([]);
|
|
386
|
+
});
|
|
387
|
+
});
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -20,7 +20,18 @@ export { useAuthForm, type AuthFormReturn, type UseAuthFormOptions } from './use
|
|
|
20
20
|
// Auto-auth from URL
|
|
21
21
|
export { useAutoAuth, type UseAutoAuthOptions } from './useAutoAuth';
|
|
22
22
|
|
|
23
|
-
// OAuth
|
|
23
|
+
// OAuth — one hook for every provider, plus the server's provider list
|
|
24
|
+
export {
|
|
25
|
+
useOAuthLogin,
|
|
26
|
+
isOAuthProviderId,
|
|
27
|
+
clearOAuthAttempt,
|
|
28
|
+
OAUTH_PROVIDER_IDS,
|
|
29
|
+
type OAuthUser,
|
|
30
|
+
type UseOAuthLoginOptions,
|
|
31
|
+
type UseOAuthLoginReturn,
|
|
32
|
+
} from './useOAuthLogin';
|
|
33
|
+
export { useOAuthProviders, type UseOAuthProvidersReturn } from './useOAuthProviders';
|
|
34
|
+
/** @deprecated Use `useOAuthLogin`. Removed in the next minor. */
|
|
24
35
|
export { useGithubAuth, type UseGithubAuthOptions, type UseGithubAuthReturn } from './useGithubAuth';
|
|
25
36
|
|
|
26
37
|
// 2FA — one consolidated hook (verify / setup / status namespaces),
|