@oxyhq/services 10.2.1 → 10.2.3
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 +7 -11
- package/lib/commonjs/ui/components/FollowButton.js +3 -1
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +119 -21
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/useAuth.js +1 -1
- package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollow.js +21 -7
- package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +3 -1
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +120 -22
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/useAuth.js +1 -1
- package/lib/module/ui/hooks/useAuth.js.map +1 -1
- package/lib/module/ui/hooks/useFollow.js +21 -7
- package/lib/module/ui/hooks/useFollow.js.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/ui/components/FollowButton.tsx +2 -2
- package/src/ui/context/OxyContext.tsx +128 -21
- package/src/ui/hooks/useAuth.ts +1 -1
- package/src/ui/hooks/useFollow.ts +16 -8
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
useCallback,
|
|
5
5
|
useContext,
|
|
6
6
|
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
7
8
|
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
@@ -250,6 +251,35 @@ const COOKIE_RESTORE_TIMEOUT = 3000;
|
|
|
250
251
|
*/
|
|
251
252
|
const COLD_BOOT_OVERALL_DEADLINE = 20000;
|
|
252
253
|
|
|
254
|
+
function getHttpStatus(error: unknown): number | undefined {
|
|
255
|
+
if (!error || typeof error !== 'object') {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if ('status' in error) {
|
|
260
|
+
const status = (error as { status?: unknown }).status;
|
|
261
|
+
if (typeof status === 'number') {
|
|
262
|
+
return status;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if ('response' in error) {
|
|
267
|
+
const response = (error as { response?: unknown }).response;
|
|
268
|
+
if (response && typeof response === 'object' && 'status' in response) {
|
|
269
|
+
const status = (response as { status?: unknown }).status;
|
|
270
|
+
if (typeof status === 'number') {
|
|
271
|
+
return status;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isUnauthorizedStatus(error: unknown): boolean {
|
|
280
|
+
return getHttpStatus(error) === 401;
|
|
281
|
+
}
|
|
282
|
+
|
|
253
283
|
/**
|
|
254
284
|
* Whether `idpOrigin` is a same-site, first-party host of the current page —
|
|
255
285
|
* i.e. it shares the page's registrable apex (last two labels), so a "no
|
|
@@ -272,7 +302,10 @@ function isSameSiteIdP(idpOrigin: string): boolean {
|
|
|
272
302
|
let idpHostname: string;
|
|
273
303
|
try {
|
|
274
304
|
idpHostname = new URL(idpOrigin).hostname;
|
|
275
|
-
} catch {
|
|
305
|
+
} catch (parseError) {
|
|
306
|
+
if (__DEV__) {
|
|
307
|
+
loggerUtil.debug('Invalid IdP origin while checking same-site session status', { component: 'OxyContext' }, parseError as unknown);
|
|
308
|
+
}
|
|
276
309
|
return false;
|
|
277
310
|
}
|
|
278
311
|
const pageHostname = window.location.hostname;
|
|
@@ -287,6 +320,12 @@ function isSameSiteIdP(idpOrigin: string): boolean {
|
|
|
287
320
|
return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
|
|
288
321
|
}
|
|
289
322
|
|
|
323
|
+
function isOnSsoCallbackPath(): boolean {
|
|
324
|
+
return isWebBrowser() && window.location.pathname === SSO_CALLBACK_PATH;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const useBrowserLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
|
|
328
|
+
|
|
290
329
|
let cachedUseFollowHook: UseFollowHook | null = null;
|
|
291
330
|
|
|
292
331
|
const loadUseFollowHook = (): UseFollowHook => {
|
|
@@ -386,7 +425,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
386
425
|
// `isAuthResolved` on the context type for the consumer contract.
|
|
387
426
|
const [authResolved, setAuthResolved] = useState(false);
|
|
388
427
|
const authResolvedRef = useRef(false);
|
|
428
|
+
const userRef = useRef<User | null>(user);
|
|
429
|
+
const isAuthenticatedRef = useRef(isAuthenticated);
|
|
430
|
+
userRef.current = user;
|
|
431
|
+
isAuthenticatedRef.current = isAuthenticated;
|
|
389
432
|
const [initialized, setInitialized] = useState(false);
|
|
433
|
+
const [ssoCallbackIntercepting, setSsoCallbackIntercepting] = useState(false);
|
|
390
434
|
const setAuthState = useAuthStore.setState;
|
|
391
435
|
|
|
392
436
|
// Keep the shared `oxyClient` singleton's token store in lockstep with the
|
|
@@ -625,6 +669,42 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
625
669
|
updateSessionsRef.current = updateSessions;
|
|
626
670
|
const clearSessionStateRef = useRef(clearSessionState);
|
|
627
671
|
clearSessionStateRef.current = clearSessionState;
|
|
672
|
+
const clearingInvalidTokenRef = useRef(false);
|
|
673
|
+
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
const handleTokenChange = (accessToken: string | null) => {
|
|
676
|
+
if (accessToken) {
|
|
677
|
+
setTokenReady(true);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (userRef.current || isAuthenticatedRef.current) {
|
|
682
|
+
setTokenReady(false);
|
|
683
|
+
if (clearingInvalidTokenRef.current) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
clearingInvalidTokenRef.current = true;
|
|
687
|
+
clearSessionStateRef.current()
|
|
688
|
+
.catch((clearError) => {
|
|
689
|
+
logger('Failed to clear invalidated auth session', clearError);
|
|
690
|
+
})
|
|
691
|
+
.finally(() => {
|
|
692
|
+
clearingInvalidTokenRef.current = false;
|
|
693
|
+
if (authResolvedRef.current) {
|
|
694
|
+
setTokenReady(true);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (authResolvedRef.current) {
|
|
701
|
+
setTokenReady(true);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
handleTokenChange(oxyServices.getAccessToken());
|
|
706
|
+
return oxyServices.onTokensChanged(handleTokenChange);
|
|
707
|
+
}, [logger, oxyServices]);
|
|
628
708
|
|
|
629
709
|
// Durable, navigation-safe session persistence.
|
|
630
710
|
//
|
|
@@ -641,12 +721,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
641
721
|
await readyStorage.setItem(storageKeys.activeSessionId, sessionId);
|
|
642
722
|
const existingIds = await readyStorage.getItem(storageKeys.sessionIds);
|
|
643
723
|
let sessionIds: string[] = [];
|
|
644
|
-
try {
|
|
724
|
+
try {
|
|
725
|
+
sessionIds = existingIds ? JSON.parse(existingIds) : [];
|
|
726
|
+
} catch (parseError) {
|
|
727
|
+
logger('Failed to parse persisted session ids; replacing corrupted storage value', parseError);
|
|
728
|
+
}
|
|
645
729
|
if (!sessionIds.includes(sessionId)) {
|
|
646
730
|
sessionIds.push(sessionId);
|
|
647
731
|
await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
|
|
648
732
|
}
|
|
649
|
-
}, [getReadyStorage, storageKeys.activeSessionId, storageKeys.sessionIds]);
|
|
733
|
+
}, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds]);
|
|
650
734
|
|
|
651
735
|
// Refs so the cold-boot restore can plant session state without widening its
|
|
652
736
|
// dependency array (mirrors the existing ref pattern above).
|
|
@@ -1293,13 +1377,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1293
1377
|
// +not-found screen before the storage-gated cold-boot `sso-return` step gets
|
|
1294
1378
|
// a chance to strip the fragment and restore the real destination.
|
|
1295
1379
|
//
|
|
1296
|
-
// This effect fires the SAME `runSsoReturn` kernel the instant we
|
|
1297
|
-
// callback path, BEFORE the cold boot (which awaits storage init).
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
// across every consumer with zero per-app code.
|
|
1380
|
+
// This effect fires the SAME `runSsoReturn` kernel the instant we hydrate ON
|
|
1381
|
+
// the callback path, BEFORE the cold boot (which awaits storage init). The
|
|
1382
|
+
// first render intentionally matches the app/router's static HTML; the
|
|
1383
|
+
// browser layout effect then hides the internal route and consumes the
|
|
1384
|
+
// callback before the first visible paint. That keeps SSR/SSG hydration stable
|
|
1385
|
+
// while still ensuring no app needs a `/__oxy/sso-callback` route.
|
|
1303
1386
|
//
|
|
1304
1387
|
// It is purely ADDITIVE. The later cold-boot `sso-return` step stays as
|
|
1305
1388
|
// defense-in-depth for the non-callback-path case; `consumeSsoReturn` strips
|
|
@@ -1315,13 +1398,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1315
1398
|
// already wired when this fires at eager-mount time. If for any reason it were
|
|
1316
1399
|
// not yet set, the later cold-boot `sso-return` step would commit it — but the
|
|
1317
1400
|
// ref IS set during render, so the eager `ok` commit works.
|
|
1318
|
-
|
|
1319
|
-
if (!
|
|
1320
|
-
|
|
1321
|
-
}
|
|
1322
|
-
if (window.location.pathname !== SSO_CALLBACK_PATH) {
|
|
1401
|
+
useBrowserLayoutEffect(() => {
|
|
1402
|
+
if (!isOnSsoCallbackPath()) {
|
|
1403
|
+
setSsoCallbackIntercepting(false);
|
|
1323
1404
|
return;
|
|
1324
1405
|
}
|
|
1406
|
+
let mounted = true;
|
|
1407
|
+
setSsoCallbackIntercepting(true);
|
|
1325
1408
|
runSsoReturnRef.current().catch((error) => {
|
|
1326
1409
|
if (__DEV__) {
|
|
1327
1410
|
loggerUtil.debug(
|
|
@@ -1330,7 +1413,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1330
1413
|
error,
|
|
1331
1414
|
);
|
|
1332
1415
|
}
|
|
1416
|
+
}).finally(() => {
|
|
1417
|
+
if (mounted) {
|
|
1418
|
+
setSsoCallbackIntercepting(false);
|
|
1419
|
+
}
|
|
1333
1420
|
});
|
|
1421
|
+
|
|
1422
|
+
return () => {
|
|
1423
|
+
mounted = false;
|
|
1424
|
+
};
|
|
1334
1425
|
}, []);
|
|
1335
1426
|
|
|
1336
1427
|
// Web SSO: automatically check for cross-domain session on web platforms.
|
|
@@ -1382,7 +1473,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1382
1473
|
let fullUser: User;
|
|
1383
1474
|
try {
|
|
1384
1475
|
fullUser = await oxyServices.getCurrentUser();
|
|
1385
|
-
} catch {
|
|
1476
|
+
} catch (profileError) {
|
|
1477
|
+
if (__DEV__) {
|
|
1478
|
+
loggerUtil.debug('Failed to fetch full user after web session; using session user fallback', { component: 'OxyContext', method: 'handleWebSSOSession' }, profileError as unknown);
|
|
1479
|
+
}
|
|
1386
1480
|
// If the profile fetch fails, fall back to the minimal data from the session
|
|
1387
1481
|
// so the user is still logged in (the store accepts User, but the shapes overlap at runtime).
|
|
1388
1482
|
fullUser = session.user as unknown as User;
|
|
@@ -1600,16 +1694,25 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1600
1694
|
|
|
1601
1695
|
// Load managed accounts when authenticated
|
|
1602
1696
|
const refreshManagedAccounts = useCallback(async (): Promise<void> => {
|
|
1603
|
-
if (!isAuthenticated)
|
|
1697
|
+
if (!isAuthenticated || !tokenReady || !oxyServices.getAccessToken()) {
|
|
1698
|
+
setManagedAccounts([]);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1604
1702
|
try {
|
|
1605
1703
|
const accounts = await oxyServices.getManagedAccounts();
|
|
1606
1704
|
setManagedAccounts(accounts);
|
|
1607
1705
|
} catch (err) {
|
|
1706
|
+
if (isUnauthorizedStatus(err)) {
|
|
1707
|
+
setManagedAccounts([]);
|
|
1708
|
+
await clearSessionStateRef.current();
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1608
1711
|
if (__DEV__) {
|
|
1609
1712
|
loggerUtil.debug('Failed to load managed accounts', { component: 'OxyContext' }, err as unknown);
|
|
1610
1713
|
}
|
|
1611
1714
|
}
|
|
1612
|
-
}, [isAuthenticated, oxyServices]);
|
|
1715
|
+
}, [isAuthenticated, oxyServices, tokenReady]);
|
|
1613
1716
|
|
|
1614
1717
|
useEffect(() => {
|
|
1615
1718
|
if (isAuthenticated && initialized && tokenReady) {
|
|
@@ -1623,9 +1726,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1623
1726
|
// Persist to storage
|
|
1624
1727
|
if (storage) {
|
|
1625
1728
|
if (userId) {
|
|
1626
|
-
storage.setItem(`${storageKeyPrefix}_acting_as`, userId).catch(() => {
|
|
1729
|
+
storage.setItem(`${storageKeyPrefix}_acting_as`, userId).catch((persistError) => {
|
|
1730
|
+
loggerUtil.debug('Failed to persist acting-as account', { component: 'OxyContext' }, persistError as unknown);
|
|
1731
|
+
});
|
|
1627
1732
|
} else {
|
|
1628
|
-
storage.removeItem(`${storageKeyPrefix}_acting_as`).catch(() => {
|
|
1733
|
+
storage.removeItem(`${storageKeyPrefix}_acting_as`).catch((persistError) => {
|
|
1734
|
+
loggerUtil.debug('Failed to clear acting-as account', { component: 'OxyContext' }, persistError as unknown);
|
|
1735
|
+
});
|
|
1629
1736
|
}
|
|
1630
1737
|
}
|
|
1631
1738
|
}, [oxyServices, storage, storageKeyPrefix]);
|
|
@@ -1718,7 +1825,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1718
1825
|
|
|
1719
1826
|
return (
|
|
1720
1827
|
<OxyContext.Provider value={contextValue}>
|
|
1721
|
-
{children}
|
|
1828
|
+
{ssoCallbackIntercepting ? null : children}
|
|
1722
1829
|
</OxyContext.Provider>
|
|
1723
1830
|
);
|
|
1724
1831
|
};
|
package/src/ui/hooks/useAuth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useMemo, useEffect } from 'react';
|
|
2
2
|
import { useFollowStore } from '../stores/followStore';
|
|
3
3
|
import { useOxy } from '../context/OxyContext';
|
|
4
|
-
import type
|
|
4
|
+
import { logger as loggerUtil, type OxyServices } from '@oxyhq/core';
|
|
5
5
|
import { useShallow } from 'zustand/react/shallow';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -18,7 +18,8 @@ import { useShallow } from 'zustand/react/shallow';
|
|
|
18
18
|
* them in selectors would cause unnecessary selector recalculations).
|
|
19
19
|
*/
|
|
20
20
|
export const useFollow = (userId?: string | string[]) => {
|
|
21
|
-
const { oxyServices } = useOxy();
|
|
21
|
+
const { oxyServices, isAuthenticated, isAuthResolved, isTokenReady } = useOxy();
|
|
22
|
+
const canUsePrivateApi = isAuthResolved && isTokenReady && isAuthenticated;
|
|
22
23
|
const userIds = useMemo(() => (Array.isArray(userId) ? userId : userId ? [userId] : []), [userId]);
|
|
23
24
|
const isSingleUser = typeof userId === 'string';
|
|
24
25
|
|
|
@@ -75,9 +76,10 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
75
76
|
// Store actions are accessed via getState() to avoid subscribing to them.
|
|
76
77
|
const toggleFollow = useCallback(async () => {
|
|
77
78
|
if (!isSingleUser || !userId) throw new Error('toggleFollow is only available for single user mode');
|
|
79
|
+
if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
|
|
78
80
|
const currentlyFollowing = useFollowStore.getState().followingUsers[userId] ?? false;
|
|
79
81
|
await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
|
|
80
|
-
}, [isSingleUser, userId, oxyServices]);
|
|
82
|
+
}, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
|
|
81
83
|
|
|
82
84
|
const setFollowStatus = useCallback((following: boolean) => {
|
|
83
85
|
if (!isSingleUser || !userId) throw new Error('setFollowStatus is only available for single user mode');
|
|
@@ -86,8 +88,9 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
86
88
|
|
|
87
89
|
const fetchStatus = useCallback(async () => {
|
|
88
90
|
if (!isSingleUser || !userId) throw new Error('fetchStatus is only available for single user mode');
|
|
91
|
+
if (!canUsePrivateApi) return;
|
|
89
92
|
await useFollowStore.getState().fetchFollowStatus(userId, oxyServices);
|
|
90
|
-
}, [isSingleUser, userId, oxyServices]);
|
|
93
|
+
}, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
|
|
91
94
|
|
|
92
95
|
const clearError = useCallback(() => {
|
|
93
96
|
if (!isSingleUser || !userId) throw new Error('clearError is only available for single user mode');
|
|
@@ -114,28 +117,33 @@ export const useFollow = (userId?: string | string[]) => {
|
|
|
114
117
|
if (!isSingleUser || !userId) return;
|
|
115
118
|
|
|
116
119
|
if ((followerCount === null || followingCount === null) && !isLoadingCounts) {
|
|
117
|
-
fetchUserCounts().catch((
|
|
120
|
+
fetchUserCounts().catch((error: unknown) => {
|
|
121
|
+
loggerUtil.warn('useFollow: fetchUserCounts failed', { component: 'useFollow' }, error);
|
|
122
|
+
});
|
|
118
123
|
}
|
|
119
124
|
}, [isSingleUser, userId, followerCount, followingCount, isLoadingCounts, fetchUserCounts]);
|
|
120
125
|
|
|
121
126
|
// Multi-user callbacks
|
|
122
127
|
const toggleFollowForUser = useCallback(async (targetUserId: string) => {
|
|
128
|
+
if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
|
|
123
129
|
const currentState = useFollowStore.getState().followingUsers[targetUserId] ?? false;
|
|
124
130
|
await useFollowStore.getState().toggleFollowUser(targetUserId, oxyServices, currentState);
|
|
125
|
-
}, [oxyServices]);
|
|
131
|
+
}, [canUsePrivateApi, oxyServices]);
|
|
126
132
|
|
|
127
133
|
const setFollowStatusForUser = useCallback((targetUserId: string, following: boolean) => {
|
|
128
134
|
useFollowStore.getState().setFollowingStatus(targetUserId, following);
|
|
129
135
|
}, []);
|
|
130
136
|
|
|
131
137
|
const fetchStatusForUser = useCallback(async (targetUserId: string) => {
|
|
138
|
+
if (!canUsePrivateApi) return;
|
|
132
139
|
await useFollowStore.getState().fetchFollowStatus(targetUserId, oxyServices);
|
|
133
|
-
}, [oxyServices]);
|
|
140
|
+
}, [canUsePrivateApi, oxyServices]);
|
|
134
141
|
|
|
135
142
|
const fetchAllStatuses = useCallback(async () => {
|
|
143
|
+
if (!canUsePrivateApi) return;
|
|
136
144
|
const store = useFollowStore.getState();
|
|
137
145
|
await Promise.all(userIds.map(uid => store.fetchFollowStatus(uid, oxyServices)));
|
|
138
|
-
}, [userIds, oxyServices]);
|
|
146
|
+
}, [canUsePrivateApi, userIds, oxyServices]);
|
|
139
147
|
|
|
140
148
|
const clearErrorForUser = useCallback((targetUserId: string) => {
|
|
141
149
|
useFollowStore.getState().clearFollowError(targetUserId);
|