@djangocfg/api 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.
Files changed (61) hide show
  1. package/README.md +2 -1
  2. package/dist/OAuthProvidersResponse-QZ566OYR.mjs +25 -0
  3. package/dist/OAuthProvidersResponse-QZ566OYR.mjs.map +1 -0
  4. package/dist/auth.cjs +526 -288
  5. package/dist/auth.cjs.map +1 -1
  6. package/dist/auth.d.cts +144 -31
  7. package/dist/auth.d.ts +144 -31
  8. package/dist/auth.mjs +409 -213
  9. package/dist/auth.mjs.map +1 -1
  10. package/dist/cfg_accounts_oauth_connections_response_200_AutoRef-N532BRPN.mjs +28 -0
  11. package/dist/cfg_accounts_oauth_connections_response_200_AutoRef-N532BRPN.mjs.map +1 -0
  12. package/dist/chunk-PCOSFQTH.mjs +10 -0
  13. package/dist/chunk-PCOSFQTH.mjs.map +1 -0
  14. package/dist/clients.cjs +34 -3
  15. package/dist/clients.cjs.map +1 -1
  16. package/dist/clients.d.cts +135 -14
  17. package/dist/clients.d.ts +135 -14
  18. package/dist/clients.mjs +34 -3
  19. package/dist/clients.mjs.map +1 -1
  20. package/dist/hooks.cjs +1 -0
  21. package/dist/hooks.cjs.map +1 -1
  22. package/dist/hooks.mjs +1 -0
  23. package/dist/hooks.mjs.map +1 -1
  24. package/dist/index.cjs +34 -3
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +169 -17
  27. package/dist/index.d.ts +169 -17
  28. package/dist/index.mjs +34 -3
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/storage.cjs +1 -0
  31. package/dist/storage.cjs.map +1 -1
  32. package/dist/storage.d.cts +1 -0
  33. package/dist/storage.d.ts +1 -0
  34. package/dist/storage.mjs +1 -0
  35. package/dist/storage.mjs.map +1 -1
  36. package/package.json +2 -2
  37. package/src/__tests__/noStorageLiterals.test.ts +1 -1
  38. package/src/_api/generated/_cfg_accounts/hooks/index.ts +2 -0
  39. package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthGoogleAuthorizeCreate.ts +68 -0
  40. package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthGoogleCallbackCreate.ts +68 -0
  41. package/src/_api/generated/_cfg_accounts/openapi.json +263 -19
  42. package/src/_api/generated/_cfg_accounts/schemas/OAuthCallbackRequestRequest.ts +2 -2
  43. package/src/_api/generated/_cfg_accounts/schemas/OAuthError.ts +2 -1
  44. package/src/_api/generated/_cfg_accounts/schemas/OAuthErrorCodeEnum.ts +9 -0
  45. package/src/_api/generated/_cfg_accounts/schemas/OAuthProviderEnum.ts +1 -1
  46. package/src/_api/generated/_cfg_accounts/schemas/OAuthProviderInfo.ts +14 -0
  47. package/src/_api/generated/_cfg_accounts/schemas/OAuthProvidersResponse.ts +2 -1
  48. package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
  49. package/src/_api/generated/helpers/keys.ts +1 -0
  50. package/src/_api/generated/openapi.json +263 -19
  51. package/src/_api/generated/sdk.gen.ts +36 -4
  52. package/src/_api/generated/types.gen.ts +136 -11
  53. package/src/auth/__tests__/oauthErrors.test.ts +23 -0
  54. package/src/auth/__tests__/passwordLogin.test.tsx +1 -0
  55. package/src/auth/__tests__/useOAuthLogin.test.tsx +387 -0
  56. package/src/auth/hooks/index.ts +12 -1
  57. package/src/auth/hooks/useGithubAuth.ts +15 -225
  58. package/src/auth/hooks/useOAuthLogin.ts +237 -0
  59. package/src/auth/hooks/useOAuthProviders.ts +76 -0
  60. package/src/auth/utils/index.ts +8 -0
  61. package/src/auth/utils/oauthErrors.ts +45 -0
@@ -1,45 +1,15 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useState } from 'react';
3
+ import { useCallback } from 'react';
4
4
 
5
- import { auth } from '../../_api/generated/helpers/auth';
6
- import { CFG_KEYS } from '../../_api/generated/helpers/keys';
7
- import { CfgAccountsOauth } from '../../_api/generated/sdk.gen';
8
- import type { OAuthTokenResponse } from '../../_api/generated/types.gen';
9
- import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
10
- import { formatAuthError } from '../utils/errors';
11
- import { authLogger } from '../utils/logger';
12
- import { useCfgRouter } from './useCfgRouter';
5
+ import { type UseOAuthLoginOptions, useOAuthLogin } from './useOAuthLogin';
13
6
 
14
- const OAUTH_STATE_KEY = CFG_KEYS.oauthState.key;
15
- const OAUTH_PROVIDER_KEY = CFG_KEYS.oauthProvider.key;
7
+ export type { OAuthUser } from './useOAuthLogin';
16
8
 
17
- /**
18
- * The user payload as the OAuth endpoint declares it.
19
- *
20
- * Derived from the generated response rather than restated, so a schema change
21
- * reaches callers through `make gen` instead of drifting silently. Today the
22
- * backend types it as a free-form object; when it names a real user model, this
23
- * narrows on its own.
24
- */
25
- export type OAuthUser = NonNullable<OAuthTokenResponse['user']>;
26
-
27
- export interface UseGithubAuthOptions {
28
- sourceUrl?: string;
29
- /**
30
- * The OAuth endpoint's OpenAPI schema types `user` as a free-form object, so
31
- * this is what the server actually promises — not a `User`. Narrow it at the
32
- * call site; typing it tighter here would be an assertion, not a fact.
33
- */
34
- onSuccess?: (user: OAuthUser, isNewUser: boolean) => void;
35
- onError?: (error: string) => void;
36
- /** Callback when 2FA is required */
37
- onRequires2FA?: (sessionId: string, shouldPrompt2FA: boolean) => void;
38
- redirectUrl?: string;
39
- /** Skip automatic redirect after success (caller handles navigation) */
40
- skipRedirect?: boolean;
41
- }
9
+ /** @deprecated Use `UseOAuthLoginOptions`. */
10
+ export type UseGithubAuthOptions = UseOAuthLoginOptions;
42
11
 
12
+ /** @deprecated Use `UseOAuthLoginReturn`. */
43
13
  export interface UseGithubAuthReturn {
44
14
  isLoading: boolean;
45
15
  error: string | null;
@@ -48,195 +18,15 @@ export interface UseGithubAuthReturn {
48
18
  }
49
19
 
50
20
  /**
51
- * Hook for GitHub OAuth authentication flow.
52
- *
53
- * Usage:
54
- * 1. Call startGithubAuth() to redirect user to GitHub
55
- * 2. After GitHub redirects back, call handleGithubCallback(code, state)
56
- *
57
- * @example
58
- * ```tsx
59
- * const { isLoading, error, startGithubAuth } = useGithubAuth({
60
- * onSuccess: (user) => router.push('/dashboard'),
61
- * onError: (error) => console.error(error),
62
- * });
63
- *
64
- * <Button onClick={startGithubAuth} disabled={isLoading}>
65
- * Continue with GitHub
66
- * </Button>
67
- * ```
21
+ * @deprecated Use `useOAuthLogin` (`start('github')`, `complete('github', code, state)`).
22
+ * Kept for one minor release; behaviour is `useOAuthLogin` with the provider fixed.
68
23
  */
69
24
  export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuthReturn => {
70
- const { sourceUrl, onSuccess, onError, onRequires2FA, redirectUrl, skipRedirect = false } = options;
71
- const router = useCfgRouter();
72
-
73
- const [isLoading, setIsLoading] = useState(false);
74
- const [error, setError] = useState<string | null>(null);
75
-
76
- /**
77
- * Start GitHub OAuth flow - redirects user to GitHub authorization page.
78
- */
79
- const startGithubAuth = useCallback(async () => {
80
- setIsLoading(true);
81
- setError(null);
82
- // Match the OTP flow: a new login must not leave an older persistent
83
- // session available after this browser is intentionally made session-only.
84
- auth.clearSession();
85
-
86
- try {
87
- authLogger.info('Starting GitHub OAuth flow...');
88
-
89
- // Track OAuth start
90
- Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
91
- category: AnalyticsCategory.AUTH,
92
- label: 'github',
93
- });
94
-
95
- // Call API to get authorization URL
96
- // The API will auto-generate redirect_uri from config if not provided
97
- const result = await CfgAccountsOauth.cfgAccountsOauthGithubAuthorizeCreate({
98
- body: {
99
- source_url: sourceUrl || (typeof window !== 'undefined' ? window.location.href : ''),
100
- },
101
- throwOnError: true,
102
- });
103
- const response = result.data;
104
-
105
- if (!response.authorization_url) {
106
- throw new Error('Failed to get authorization URL');
107
- }
108
-
109
- authLogger.info('Redirecting to GitHub...', response.authorization_url);
110
-
111
- if (typeof window !== 'undefined') {
112
- sessionStorage.setItem(OAUTH_STATE_KEY, response.state);
113
- sessionStorage.setItem(OAUTH_PROVIDER_KEY, 'github');
114
- }
115
-
116
- window.location.href = response.authorization_url;
117
-
118
- } catch (err) {
119
- const errorMessage = formatAuthError(err, 'Failed to start GitHub authentication');
120
- authLogger.error('GitHub OAuth start error:', err);
121
- setError(errorMessage);
122
- onError?.(errorMessage);
123
-
124
- // Track OAuth error
125
- Analytics.event(AnalyticsEvent.AUTH_OAUTH_FAIL, {
126
- category: AnalyticsCategory.AUTH,
127
- label: 'github',
128
- });
129
- } finally {
130
- setIsLoading(false);
131
- }
132
- }, [sourceUrl, onError]);
133
-
134
- /**
135
- * Handle GitHub OAuth callback - exchanges code for JWT tokens.
136
- *
137
- * @param code - Authorization code from GitHub callback
138
- * @param state - State token for CSRF verification
139
- */
140
- const handleGithubCallback = useCallback(async (code: string, state: string) => {
141
- setIsLoading(true);
142
- setError(null);
143
-
144
- try {
145
- authLogger.info('Processing GitHub OAuth callback...');
146
-
147
- // Verify state matches what we stored
148
- if (typeof window !== 'undefined') {
149
- const storedState = sessionStorage.getItem(OAUTH_STATE_KEY);
150
- if (storedState && storedState !== state) {
151
- throw new Error('Invalid OAuth state - possible CSRF attack');
152
- }
153
- // Clear stored state
154
- sessionStorage.removeItem(OAUTH_STATE_KEY);
155
- sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
156
-
157
- }
158
-
159
- const result = await CfgAccountsOauth.cfgAccountsOauthGithubCallbackCreate({
160
- body: { code, state },
161
- throwOnError: true,
162
- });
163
- const response = result.data;
164
-
165
- // Check if 2FA is required
166
- if (response.requires_2fa && response.session_id) {
167
- authLogger.info('GitHub OAuth requires 2FA, session:', response.session_id);
168
-
169
- // Track 2FA requirement
170
- Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
171
- category: AnalyticsCategory.AUTH,
172
- label: 'github-2fa-required',
173
- });
174
-
175
- // Call 2FA callback
176
- onRequires2FA?.(response.session_id, response.should_prompt_2fa || false);
177
- return;
178
- }
179
-
180
- if (!response.access || !response.refresh) {
181
- throw new Error('Invalid response from OAuth callback');
182
- }
183
-
184
- authLogger.info('GitHub OAuth successful, user:', response.user);
185
-
186
- // The ONE write path — atomic pair + session notification. Storage is
187
- // always localStorage; session length is the server's call.
188
- // See AccountsContext.verifyOTP.
189
- auth.setSession({ access: response.access, refresh: response.refresh });
190
-
191
- // Track successful OAuth
192
- Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
193
- category: AnalyticsCategory.AUTH,
194
- label: 'github',
195
- });
196
-
197
- // Set user ID for future tracking
198
- if (response.user?.id) {
199
- Analytics.setUser(String(response.user.id));
200
- }
201
-
202
- // Call success callback.
203
- //
204
- // Guarded because the schema allows `user: null` — the 2FA branch returns
205
- // no user — and five lines up this same value is read through `?.`. The
206
- // old `any` let one line be careful and the next not.
207
- if (response.user) {
208
- onSuccess?.(response.user, response.is_new_user || false);
209
- }
210
-
211
- // Redirect (unless skipRedirect is true)
212
- if (!skipRedirect) {
213
- // Use hardPush for full page reload - ensures all React contexts reinitialize
214
- const finalRedirectUrl = redirectUrl || '/private';
215
- router.hardPush(finalRedirectUrl);
216
- }
217
-
218
- return;
219
-
220
- } catch (err) {
221
- const errorMessage = formatAuthError(err, 'GitHub authentication failed');
222
- authLogger.error('GitHub OAuth callback error:', err);
223
- setError(errorMessage);
224
- onError?.(errorMessage);
225
-
226
- // Track OAuth error
227
- Analytics.event(AnalyticsEvent.AUTH_OAUTH_FAIL, {
228
- category: AnalyticsCategory.AUTH,
229
- label: 'github',
230
- });
231
- } finally {
232
- setIsLoading(false);
233
- }
234
- }, [onSuccess, onError, onRequires2FA, redirectUrl, router, skipRedirect]);
235
-
236
- return {
237
- isLoading,
238
- error,
239
- startGithubAuth,
240
- handleGithubCallback,
241
- };
25
+ const { isLoading, error, start, complete } = useOAuthLogin(options);
26
+ const startGithubAuth = useCallback(() => start('github'), [start]);
27
+ const handleGithubCallback = useCallback(
28
+ (code: string, state: string) => complete('github', code, state),
29
+ [complete],
30
+ );
31
+ return { isLoading, error, startGithubAuth, handleGithubCallback };
242
32
  };
@@ -0,0 +1,237 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ import { auth } from '../../_api/generated/helpers/auth';
6
+ import { CFG_KEYS, cfgStorage } from '../../_api/generated/helpers/keys';
7
+ import { CfgAccountsOauth } from '../../_api/generated/sdk.gen';
8
+ import type {
9
+ OAuthAuthorizeRequestRequest,
10
+ OAuthAuthorizeResponse,
11
+ OAuthCallbackRequestRequest,
12
+ OAuthTokenResponse,
13
+ } from '../../_api/generated/types.gen';
14
+ import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
15
+ import { authLogger } from '../utils/logger';
16
+ import { type OAuthFailureCode, type OAuthProviderId, readOAuthFailure } from '../utils/oauthErrors';
17
+ import { consumeSavedRedirect } from './useAuthRedirect';
18
+ import { useCfgRouter } from './useCfgRouter';
19
+
20
+ /**
21
+ * The user payload as the OAuth endpoint declares it.
22
+ *
23
+ * Derived from the generated response rather than restated, so a schema change
24
+ * reaches callers through `make generate` instead of drifting silently.
25
+ */
26
+ export type OAuthUser = NonNullable<OAuthTokenResponse['user']>;
27
+
28
+ interface OAuthAdapter {
29
+ authorize: (body: OAuthAuthorizeRequestRequest) => Promise<OAuthAuthorizeResponse>;
30
+ callback: (body: OAuthCallbackRequestRequest) => Promise<OAuthTokenResponse>;
31
+ }
32
+
33
+ /**
34
+ * One entry per provider the backend can offer. Keyed by the generated enum:
35
+ * when the backend adds a provider, this stops compiling until it is wired.
36
+ */
37
+ const ADAPTERS: Record<OAuthProviderId, OAuthAdapter> = {
38
+ github: {
39
+ authorize: async (body) =>
40
+ (await CfgAccountsOauth.cfgAccountsOauthGithubAuthorizeCreate({ body, throwOnError: true })).data,
41
+ callback: async (body) =>
42
+ (await CfgAccountsOauth.cfgAccountsOauthGithubCallbackCreate({ body, throwOnError: true })).data,
43
+ },
44
+ google: {
45
+ authorize: async (body) =>
46
+ (await CfgAccountsOauth.cfgAccountsOauthGoogleAuthorizeCreate({ body, throwOnError: true })).data,
47
+ callback: async (body) =>
48
+ (await CfgAccountsOauth.cfgAccountsOauthGoogleCallbackCreate({ body, throwOnError: true })).data,
49
+ },
50
+ };
51
+
52
+ /** Provider ids this client can run, in the order the adapters are declared. */
53
+ export const OAUTH_PROVIDER_IDS = Object.keys(ADAPTERS) as OAuthProviderId[];
54
+
55
+ export const isOAuthProviderId = (value: unknown): value is OAuthProviderId =>
56
+ typeof value === 'string' && Object.hasOwn(ADAPTERS, value);
57
+
58
+ const STATE_KEY = CFG_KEYS.oauthState.key;
59
+ const PROVIDER_KEY = CFG_KEYS.oauthProvider.key;
60
+
61
+ /** Forget the stored state: the attempt is over without an exchange (Cancel, a provider error). */
62
+ export const clearOAuthAttempt = (): void => {
63
+ cfgStorage.remove(STATE_KEY);
64
+ cfgStorage.remove(PROVIDER_KEY);
65
+ };
66
+
67
+ export interface UseOAuthLoginOptions {
68
+ sourceUrl?: string;
69
+ /** The endpoint types `user` as a free-form object; narrow it at the call site. */
70
+ onSuccess?: (user: OAuthUser, isNewUser: boolean, provider: OAuthProviderId) => void;
71
+ /** `message` is server prose or an English fallback; `code` lets the caller choose its own copy. */
72
+ onError?: (message: string, code?: OAuthFailureCode) => void;
73
+ /** Called when 2FA is required instead of a session. */
74
+ onRequires2FA?: (sessionId: string, shouldPrompt2FA: boolean) => void;
75
+ redirectUrl?: string;
76
+ /** Skip the automatic redirect after success (the caller navigates). */
77
+ skipRedirect?: boolean;
78
+ }
79
+
80
+ export interface UseOAuthLoginReturn {
81
+ /** True from the click until the page leaves (start) or the exchange settles (complete). */
82
+ isLoading: boolean;
83
+ /** The provider whose flow is in flight; disable the other buttons, show progress on this one. */
84
+ loadingProvider: OAuthProviderId | null;
85
+ error: string | null;
86
+ errorCode: OAuthFailureCode | null;
87
+ /** Redirects the tab to the provider. Resolves only if it failed to start. */
88
+ start: (provider: OAuthProviderId) => Promise<void>;
89
+ /** Exchanges the `code` the provider returned. The stored `state` must match; there is no unchecked path. */
90
+ complete: (provider: OAuthProviderId, code: string, state: string) => Promise<void>;
91
+ }
92
+
93
+ /**
94
+ * Sign in with a third-party provider: one hook for every provider.
95
+ *
96
+ * Flow, full page in the same tab so the saved return URL survives:
97
+ * `start(provider)` stores the server's `state`, then navigates to the provider.
98
+ * The provider returns to `/auth?provider=<id>&code&state`; `complete(...)`
99
+ * verifies that `state` against the stored one (login CSRF defence), exchanges
100
+ * the code and writes the session.
101
+ */
102
+ export const useOAuthLogin = (options: UseOAuthLoginOptions = {}): UseOAuthLoginReturn => {
103
+ const router = useCfgRouter();
104
+
105
+ const optionsRef = useRef(options);
106
+ optionsRef.current = options;
107
+ const routerRef = useRef(router);
108
+ routerRef.current = router;
109
+
110
+ const [loadingProvider, setLoadingProvider] = useState<OAuthProviderId | null>(null);
111
+ const [error, setError] = useState<string | null>(null);
112
+ const [errorCode, setErrorCode] = useState<OAuthFailureCode | null>(null);
113
+
114
+ // Coming back with the back button restores this page from the bfcache with
115
+ // the spinner still on; the provider page was left, so the flow is over.
116
+ useEffect(() => {
117
+ const onShow = (event: PageTransitionEvent) => {
118
+ if (event.persisted) setLoadingProvider(null);
119
+ };
120
+ window.addEventListener('pageshow', onShow);
121
+ return () => window.removeEventListener('pageshow', onShow);
122
+ }, []);
123
+
124
+ const fail = useCallback((provider: OAuthProviderId, message: string, code?: OAuthFailureCode) => {
125
+ setError(message);
126
+ setErrorCode(code ?? null);
127
+ setLoadingProvider(null);
128
+ optionsRef.current.onError?.(message, code);
129
+ Analytics.event(AnalyticsEvent.AUTH_OAUTH_FAIL, {
130
+ category: AnalyticsCategory.AUTH,
131
+ label: provider,
132
+ });
133
+ }, []);
134
+
135
+ const start = useCallback(async (provider: OAuthProviderId) => {
136
+ setLoadingProvider(provider);
137
+ setError(null);
138
+ setErrorCode(null);
139
+ // Match the OTP flow: a new login must not leave an older persistent
140
+ // session available after this browser is intentionally made session-only.
141
+ auth.clearSession();
142
+
143
+ try {
144
+ Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
145
+ category: AnalyticsCategory.AUTH,
146
+ label: provider,
147
+ });
148
+
149
+ // No `redirect_uri`: the server derives it from the request origin.
150
+ const sourceUrl = optionsRef.current.sourceUrl || window.location.href;
151
+ const response = await ADAPTERS[provider].authorize({ source_url: sourceUrl });
152
+ if (!response.authorization_url || !response.state) {
153
+ throw new Error('Failed to get authorization URL');
154
+ }
155
+
156
+ cfgStorage.write(STATE_KEY, response.state);
157
+ cfgStorage.write(PROVIDER_KEY, provider);
158
+ authLogger.info(`Redirecting to ${provider}...`);
159
+ // Loading stays on: the tab is leaving.
160
+ window.location.href = response.authorization_url;
161
+ } catch (err) {
162
+ authLogger.error(`${provider} OAuth start error:`, err);
163
+ const failure = readOAuthFailure(err, 'Failed to start authentication');
164
+ fail(provider, failure.message, failure.code);
165
+ }
166
+ }, [fail]);
167
+
168
+ const complete = useCallback(async (provider: OAuthProviderId, code: string, state: string) => {
169
+ setLoadingProvider(provider);
170
+ setError(null);
171
+ setErrorCode(null);
172
+
173
+ // The stored state is the proof that THIS browser started the flow. A
174
+ // missing one is as wrong as a different one: an attacker's link carries a
175
+ // valid code and state for the attacker's account. Spent before any await.
176
+ const storedState = cfgStorage.read(STATE_KEY);
177
+ const storedProvider = cfgStorage.read(PROVIDER_KEY);
178
+ clearOAuthAttempt();
179
+ if (!storedState || storedState !== state || (storedProvider && storedProvider !== provider)) {
180
+ authLogger.warn(`${provider} OAuth callback rejected: state does not match this browser`);
181
+ fail(provider, 'Invalid sign-in attempt. Please try again.', 'invalid_state');
182
+ return;
183
+ }
184
+
185
+ try {
186
+ const response = await ADAPTERS[provider].callback({ code, state });
187
+
188
+ if (response.requires_2fa && response.session_id) {
189
+ Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
190
+ category: AnalyticsCategory.AUTH,
191
+ label: `${provider}-2fa-required`,
192
+ });
193
+ optionsRef.current.onRequires2FA?.(response.session_id, response.should_prompt_2fa || false);
194
+ setLoadingProvider(null);
195
+ return;
196
+ }
197
+
198
+ if (!response.access || !response.refresh) {
199
+ throw new Error('Invalid response from OAuth callback');
200
+ }
201
+
202
+ // The ONE write path — atomic pair + session notification.
203
+ auth.setSession({ access: response.access, refresh: response.refresh });
204
+
205
+ Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
206
+ category: AnalyticsCategory.AUTH,
207
+ label: provider,
208
+ });
209
+ if (response.user?.id) {
210
+ Analytics.setUser(String(response.user.id));
211
+ }
212
+ if (response.user) {
213
+ optionsRef.current.onSuccess?.(response.user, response.is_new_user || false, provider);
214
+ }
215
+
216
+ setLoadingProvider(null);
217
+ if (!optionsRef.current.skipRedirect) {
218
+ // Full reload so every React context reinitialises with the new session.
219
+ // The saved back-url (where a guard bounced the visitor from) comes first.
220
+ routerRef.current.hardPush(consumeSavedRedirect() || optionsRef.current.redirectUrl || '/private');
221
+ }
222
+ } catch (err) {
223
+ authLogger.error(`${provider} OAuth callback error:`, err);
224
+ const failure = readOAuthFailure(err, 'Authentication failed');
225
+ fail(provider, failure.message, failure.code);
226
+ }
227
+ }, [fail]);
228
+
229
+ return {
230
+ isLoading: loadingProvider !== null,
231
+ loadingProvider,
232
+ error,
233
+ errorCode,
234
+ start,
235
+ complete,
236
+ };
237
+ };
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useSyncExternalStore } from 'react';
4
+
5
+ import { useCfgAccountsOauthProvidersRetrieve } from '../../_api/generated/_cfg_accounts/hooks/useCfgAccountsOauthProvidersRetrieve';
6
+ import { CFG_KEYS, cfgStorage } from '../../_api/generated/helpers/keys';
7
+ import type { OAuthProviderInfo } from '../../_api/generated/types.gen';
8
+ import { OAUTH_PROVIDER_IDS } from './useOAuthLogin';
9
+
10
+ export interface UseOAuthProvidersReturn {
11
+ /** Providers the server has configured, in the server's display order. Empty until ready and on failure. */
12
+ providers: OAuthProviderInfo[];
13
+ /** False only while the first request is in flight and nothing is cached. */
14
+ isReady: boolean;
15
+ }
16
+
17
+ const EMPTY: OAuthProviderInfo[] = [];
18
+ const CACHE_KEY = CFG_KEYS.oauthProviders.key;
19
+
20
+ const isInfo = (value: unknown): value is OAuthProviderInfo =>
21
+ typeof value === 'object' && value !== null && typeof (value as { id?: unknown }).id === 'string';
22
+
23
+ // useSyncExternalStore needs a stable snapshot: parse again only when the stored text changed.
24
+ let lastRaw: string | null = null;
25
+ let lastParsed: OAuthProviderInfo[] | undefined;
26
+
27
+ /** The list the previous page of this tab fetched; `undefined` when none or unreadable. */
28
+ const readCached = (): OAuthProviderInfo[] | undefined => {
29
+ const raw = cfgStorage.read(CACHE_KEY);
30
+ if (raw === lastRaw) return lastParsed;
31
+ lastRaw = raw;
32
+ try {
33
+ const parsed: unknown = raw === null ? null : JSON.parse(raw);
34
+ lastParsed = Array.isArray(parsed) && parsed.every(isInfo) ? parsed : undefined;
35
+ } catch {
36
+ lastParsed = undefined;
37
+ }
38
+ return lastParsed;
39
+ };
40
+
41
+ const subscribe = () => () => {};
42
+ const serverSnapshot = () => undefined;
43
+
44
+ /**
45
+ * The sign-in providers the server offers (`GET /cfg/accounts/oauth/providers/`).
46
+ *
47
+ * Fetched once per page session and kept in `sessionStorage`, so a reload or
48
+ * "Try again" after a failed callback shows the buttons at once; the server
49
+ * answer always wins. A failure yields the cached list, else `[]`, with
50
+ * `isReady: true`: the email form never waits on, or breaks because of, this
51
+ * list. Ids this client has no adapter for (a newer backend) are dropped.
52
+ */
53
+ export const useOAuthProviders = (): UseOAuthProvidersReturn => {
54
+ // Server and first client render see `undefined`, so hydration matches.
55
+ const cached = useSyncExternalStore(subscribe, readCached, serverSnapshot);
56
+
57
+ const { data, error, isLoading } = useCfgAccountsOauthProvidersRetrieve(undefined, {
58
+ revalidateOnFocus: false,
59
+ revalidateOnReconnect: false,
60
+ revalidateOnMount: true,
61
+ shouldRetryOnError: false,
62
+ dedupingInterval: 60 * 60 * 1000,
63
+ fallbackData: cached && { providers: cached },
64
+ onSuccess: (response) => {
65
+ cfgStorage.writeJSON(CACHE_KEY, response.providers);
66
+ },
67
+ });
68
+
69
+ const providers = useMemo(
70
+ () => data?.providers.filter((p) => OAUTH_PROVIDER_IDS.includes(p.id)) ?? EMPTY,
71
+ [data],
72
+ );
73
+
74
+ // SWR keeps `isLoading` on while it revalidates fallback data; a cached list is already usable.
75
+ return { providers, isReady: !isLoading || Boolean(error) || cached !== undefined };
76
+ };
@@ -7,6 +7,14 @@ export {
7
7
  type AuthErrorCopy,
8
8
  type AuthErrorKey,
9
9
  } from './errors';
10
+ export {
11
+ readOAuthFailure,
12
+ type OAuthClientErrorCode,
13
+ type OAuthErrorCode,
14
+ type OAuthFailure,
15
+ type OAuthFailureCode,
16
+ type OAuthProviderId,
17
+ } from './oauthErrors';
10
18
  export {
11
19
  resolveGuardIsLoading,
12
20
  shouldRedirectToAuth,
@@ -0,0 +1,45 @@
1
+ import { OAuthErrorCodeEnum, type OAuthProviderEnum } from '../../_api/generated/types.gen';
2
+ import { formatAuthError } from './errors';
3
+
4
+ /** A provider the server can offer. Derived from the generated enum, so a new backend provider is a type error until it gets an adapter. */
5
+ export type OAuthProviderId = `${OAuthProviderEnum}`;
6
+
7
+ /** Every `error` value the OAuth endpoints can return (closed set from the OpenAPI schema). */
8
+ export type OAuthErrorCode = `${OAuthErrorCodeEnum}`;
9
+
10
+ /** Failures the browser detects itself, before or instead of a server answer. */
11
+ export type OAuthClientErrorCode = 'cancelled' | 'unknown_provider';
12
+
13
+ export type OAuthFailureCode = OAuthErrorCode | OAuthClientErrorCode;
14
+
15
+ export interface OAuthFailure {
16
+ /** Set when the cause is one the UI can explain; absent for transport faults. */
17
+ code?: OAuthFailureCode;
18
+ /** English text for callers without a translator: server prose, else a generic sentence. */
19
+ message: string;
20
+ }
21
+
22
+ // Read from the generated enum: a code the backend adds arrives with `make generate`.
23
+ const SERVER_CODES: ReadonlySet<string> = new Set<string>(Object.values(OAuthErrorCodeEnum));
24
+
25
+ const record = (value: unknown): Record<string, unknown> | null =>
26
+ typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
27
+
28
+ /**
29
+ * Read `{error, error_description}` off a thrown API error.
30
+ *
31
+ * The transport keeps the parsed body on `response`; `formatAuthError` alone
32
+ * would surface the machine code (`invalid_state`) as if it were prose.
33
+ */
34
+ export const readOAuthFailure = (err: unknown, fallback: string): OAuthFailure => {
35
+ const body = record(record(err)?.response);
36
+ const code = body?.error;
37
+ if (typeof code === 'string' && SERVER_CODES.has(code)) {
38
+ const description = body?.error_description;
39
+ return {
40
+ code: code as OAuthErrorCode,
41
+ message: typeof description === 'string' && description ? description : fallback,
42
+ };
43
+ }
44
+ return { message: formatAuthError(err, fallback) };
45
+ };