@oxyhq/auth 2.0.4 → 2.0.6

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 (58) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/WebOxyProvider.js +37 -0
  3. package/dist/cjs/hooks/mutations/index.js +5 -1
  4. package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
  5. package/dist/cjs/hooks/mutations/useAppData.js +133 -0
  6. package/dist/cjs/hooks/queries/appDataQueryKeys.js +46 -0
  7. package/dist/cjs/hooks/queries/index.js +8 -1
  8. package/dist/cjs/hooks/queries/useAppData.js +87 -0
  9. package/dist/cjs/hooks/queryClient.js +136 -92
  10. package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
  11. package/dist/cjs/hooks/useSessionSocket.js +81 -94
  12. package/dist/cjs/index.js +8 -3
  13. package/dist/cjs/utils/sessionHelpers.js +3 -1
  14. package/dist/cjs/utils/storageHelpers.js +36 -10
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/WebOxyProvider.js +38 -1
  17. package/dist/esm/hooks/mutations/index.js +2 -0
  18. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  19. package/dist/esm/hooks/mutations/useAppData.js +128 -0
  20. package/dist/esm/hooks/queries/appDataQueryKeys.js +42 -0
  21. package/dist/esm/hooks/queries/index.js +3 -0
  22. package/dist/esm/hooks/queries/useAppData.js +82 -0
  23. package/dist/esm/hooks/queryClient.js +132 -89
  24. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  25. package/dist/esm/hooks/useSessionSocket.js +81 -94
  26. package/dist/esm/index.js +3 -3
  27. package/dist/esm/utils/sessionHelpers.js +3 -1
  28. package/dist/esm/utils/storageHelpers.js +36 -10
  29. package/dist/types/.tsbuildinfo +1 -1
  30. package/dist/types/WebOxyProvider.d.ts +1 -1
  31. package/dist/types/hooks/mutations/index.d.ts +1 -0
  32. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  33. package/dist/types/hooks/mutations/useAppData.d.ts +47 -0
  34. package/dist/types/hooks/queries/appDataQueryKeys.d.ts +24 -0
  35. package/dist/types/hooks/queries/index.d.ts +2 -0
  36. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  37. package/dist/types/hooks/queries/useAppData.d.ts +46 -0
  38. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  39. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  40. package/dist/types/hooks/queryClient.d.ts +24 -10
  41. package/dist/types/hooks/useAssets.d.ts +1 -1
  42. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  43. package/dist/types/index.d.ts +3 -3
  44. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  45. package/package.json +22 -3
  46. package/src/WebOxyProvider.tsx +39 -1
  47. package/src/hooks/mutations/index.ts +3 -0
  48. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  49. package/src/hooks/mutations/useAppData.ts +167 -0
  50. package/src/hooks/queries/appDataQueryKeys.ts +53 -0
  51. package/src/hooks/queries/index.ts +4 -0
  52. package/src/hooks/queries/useAppData.ts +105 -0
  53. package/src/hooks/queryClient.ts +140 -83
  54. package/src/hooks/useFileDownloadUrl.ts +15 -39
  55. package/src/hooks/useSessionSocket.ts +123 -91
  56. package/src/index.ts +7 -1
  57. package/src/utils/sessionHelpers.ts +3 -1
  58. package/src/utils/storageHelpers.ts +49 -10
@@ -27,12 +27,40 @@ function readTokenFromStorage(): string | null {
27
27
  if (typeof window === 'undefined') return null;
28
28
  try {
29
29
  return window.localStorage.getItem(LS_ACCESS_TOKEN_KEY);
30
- } catch {
30
+ } catch (err) {
31
+ console.warn('[oxy.session-socket] localStorage read failed:', err);
31
32
  return null;
32
33
  }
33
34
  }
34
35
 
35
- type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => unknown;
36
+ /**
37
+ * Minimal subset of the socket.io-client Socket API used by this hook.
38
+ * We avoid importing socket.io-client types directly because the package
39
+ * is an optional peer dependency.
40
+ *
41
+ * `on()` uses a generic per-call handler signature because each socket event
42
+ * carries its own payload shape.
43
+ */
44
+ interface MinimalSocket {
45
+ id?: string;
46
+ disconnected: boolean;
47
+ connect: () => void;
48
+ disconnect: () => void;
49
+ on<Args extends unknown[] = unknown[]>(
50
+ event: string,
51
+ handler: (...args: Args) => void
52
+ ): void;
53
+ }
54
+
55
+ /**
56
+ * Socket extended with a private property used to track the cross-tab
57
+ * storage event listener so cleanup can remove it.
58
+ */
59
+ interface SocketWithStorageHandler extends MinimalSocket {
60
+ __oxyStorageHandler?: (event: StorageEvent) => void;
61
+ }
62
+
63
+ type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => MinimalSocket;
36
64
 
37
65
  let _io: SocketIOFactory | null = null;
38
66
  let _ioLoadAttempted = false;
@@ -42,11 +70,14 @@ async function getSocketIO(): Promise<SocketIOFactory | null> {
42
70
  if (_ioLoadAttempted) return null;
43
71
  _ioLoadAttempted = true;
44
72
  try {
45
- // eslint-disable-next-line @typescript-eslint/no-require-imports
46
- const mod: any = await import('socket.io-client');
47
- _io = (mod.io ?? mod.default) as SocketIOFactory;
73
+ const mod = (await import('socket.io-client')) as {
74
+ io?: SocketIOFactory;
75
+ default?: SocketIOFactory;
76
+ };
77
+ _io = mod.io ?? mod.default ?? null;
48
78
  return _io;
49
- } catch {
79
+ } catch (err) {
80
+ console.warn('[oxy.session-socket] socket.io-client import failed:', err);
50
81
  debug.warn('socket.io-client is not installed. useSessionSocket will be disabled. Install it with: bun add socket.io-client');
51
82
  return null;
52
83
  }
@@ -72,7 +103,7 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
72
103
  return active?.deviceId ?? null;
73
104
  }, [sessions, activeSessionId]);
74
105
 
75
- const socketRef = useRef<any>(null);
106
+ const socketRef = useRef<SocketWithStorageHandler | null>(null);
76
107
 
77
108
  // Store callbacks and values in refs to avoid reconnecting when they change
78
109
  const clearSessionStateRef = useRef(clearSessionState);
@@ -128,7 +159,7 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
128
159
  // If no token is available at all, we skip the initial connect and let
129
160
  // the storage listener or retry logic connect when a token appears.
130
161
  const token = resolveToken();
131
- socketRef.current = ioFn(baseURL, {
162
+ const socket: SocketWithStorageHandler = ioFn(baseURL, {
132
163
  transports: ['websocket'],
133
164
  autoConnect: !!token, // don't auto-connect when there is no token
134
165
  auth: (cb: (data: { token: string }) => void) => {
@@ -145,7 +176,7 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
145
176
  cb({ token: resolved });
146
177
  },
147
178
  });
148
- const socket = socketRef.current;
179
+ socketRef.current = socket;
149
180
 
150
181
  // Server auto-joins the user to `user:<userId>` room on connection
151
182
  const handleConnect = () => {
@@ -188,6 +219,27 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
188
219
  return Promise.resolve();
189
220
  };
190
221
 
222
+ const triggerLocalSignOut = async (toastMessage: string, errorContext: string) => {
223
+ if (onRemoteSignOutRef.current) {
224
+ onRemoteSignOutRef.current();
225
+ } else {
226
+ toast.info(toastMessage);
227
+ }
228
+ // Clear local state since the server has already removed the session.
229
+ // Await so storage cleanup completes before any subsequent navigation.
230
+ try {
231
+ await clearSessionStateRef.current();
232
+ } catch (error) {
233
+ if (process.env.NODE_ENV !== 'production') {
234
+ logger.error(
235
+ `Failed to clear session state after ${errorContext}`,
236
+ error instanceof Error ? error : new Error(String(error)),
237
+ { component: 'useSessionSocket' },
238
+ );
239
+ }
240
+ }
241
+ };
242
+
191
243
  const handleSessionUpdate = async (data: {
192
244
  type: string;
193
245
  sessionId?: string;
@@ -199,96 +251,74 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
199
251
  const currentActiveSessionId = activeSessionIdRef.current;
200
252
  const deviceId = currentDeviceIdRef.current;
201
253
 
202
- // Handle different event types
203
- if (data.type === 'session_removed') {
204
- // Track removed session
205
- if (data.sessionId && onSessionRemovedRef.current) {
206
- onSessionRemovedRef.current(data.sessionId);
207
- }
208
-
209
- // If the removed sessionId matches the current activeSessionId, immediately clear state
210
- if (data.sessionId === currentActiveSessionId) {
211
- if (onRemoteSignOutRef.current) {
212
- onRemoteSignOutRef.current();
213
- } else {
214
- toast.info('You have been signed out remotely.');
254
+ // Strict whitelist. Every event type that may sign the user out must
255
+ // appear in the switch. Anything unknown falls through to `default`,
256
+ // which only logs in dev. This guards against future server-side event
257
+ // additions (e.g. `session_created` after a successful sign-in)
258
+ // accidentally triggering sign-out via a fallback branch that compares
259
+ // `data.sessionId === currentActiveSessionId` — that branch would match
260
+ // the user's NEW session id and trigger an instant remote sign-out
261
+ // toast on every login.
262
+ switch (data.type) {
263
+ case 'session_removed': {
264
+ if (data.sessionId && onSessionRemovedRef.current) {
265
+ onSessionRemovedRef.current(data.sessionId);
215
266
  }
216
- try {
217
- await clearSessionStateRef.current();
218
- } catch (error) {
219
- if (process.env.NODE_ENV !== 'production') {
220
- logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
221
- }
267
+ if (data.sessionId && data.sessionId === currentActiveSessionId) {
268
+ await triggerLocalSignOut('You have been signed out remotely.', 'session_removed');
269
+ } else {
270
+ refreshSessions();
222
271
  }
223
- } else {
224
- refreshSessions();
272
+ break;
225
273
  }
226
- } else if (data.type === 'device_removed') {
227
- // Track all removed sessions from this device
228
- if (data.sessionIds && onSessionRemovedRef.current) {
229
- for (const sessionId of data.sessionIds) {
230
- onSessionRemovedRef.current(sessionId);
274
+ case 'device_removed': {
275
+ if (data.sessionIds && onSessionRemovedRef.current) {
276
+ for (const sessionId of data.sessionIds) {
277
+ onSessionRemovedRef.current(sessionId);
278
+ }
231
279
  }
232
- }
233
-
234
- // If the removed deviceId matches the current device, immediately clear state
235
- if (data.deviceId && data.deviceId === deviceId) {
236
- if (onRemoteSignOutRef.current) {
237
- onRemoteSignOutRef.current();
280
+ if (data.deviceId && deviceId && data.deviceId === deviceId) {
281
+ await triggerLocalSignOut(
282
+ 'This device has been removed. You have been signed out.',
283
+ 'device_removed',
284
+ );
238
285
  } else {
239
- toast.info('This device has been removed. You have been signed out.');
240
- }
241
- try {
242
- await clearSessionStateRef.current();
243
- } catch (error) {
244
- if (process.env.NODE_ENV !== 'production') {
245
- logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
246
- }
286
+ refreshSessions();
247
287
  }
248
- } else {
249
- refreshSessions();
288
+ break;
250
289
  }
251
- } else if (data.type === 'sessions_removed') {
252
- // Track all removed sessions
253
- if (data.sessionIds && onSessionRemovedRef.current) {
254
- for (const sessionId of data.sessionIds) {
255
- onSessionRemovedRef.current(sessionId);
290
+ case 'sessions_removed': {
291
+ if (data.sessionIds && onSessionRemovedRef.current) {
292
+ for (const sessionId of data.sessionIds) {
293
+ onSessionRemovedRef.current(sessionId);
294
+ }
256
295
  }
257
- }
258
-
259
- // If the current activeSessionId is in the removed sessionIds list, immediately clear state
260
- if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
261
- if (onRemoteSignOutRef.current) {
262
- onRemoteSignOutRef.current();
296
+ if (
297
+ data.sessionIds &&
298
+ currentActiveSessionId &&
299
+ data.sessionIds.includes(currentActiveSessionId)
300
+ ) {
301
+ await triggerLocalSignOut('You have been signed out remotely.', 'sessions_removed');
263
302
  } else {
264
- toast.info('You have been signed out remotely.');
303
+ refreshSessions();
265
304
  }
266
- try {
267
- await clearSessionStateRef.current();
268
- } catch (error) {
269
- if (process.env.NODE_ENV !== 'production') {
270
- logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
271
- }
272
- }
273
- } else {
305
+ break;
306
+ }
307
+ case 'session_created':
308
+ case 'session_update': {
309
+ // Lifecycle event for the current user. Just resync the sessions
310
+ // list — never sign out.
274
311
  refreshSessions();
312
+ break;
275
313
  }
276
- } else {
277
- // For other event types (e.g., session_created), refresh sessions
278
- refreshSessions();
279
-
280
- // If the current session was logged out (legacy behavior), handle it specially
281
- if (data.sessionId === currentActiveSessionId) {
282
- if (onRemoteSignOutRef.current) {
283
- onRemoteSignOutRef.current();
284
- } else {
285
- toast.info('You have been signed out remotely.');
286
- }
287
- try {
288
- await clearSessionStateRef.current();
289
- } catch (error) {
290
- debug.error('Failed to clear session state after session_update:', error);
314
+ default: {
315
+ if (process.env.NODE_ENV !== 'production') {
316
+ logger.warn('Unknown session socket event type', {
317
+ component: 'useSessionSocket',
318
+ type: data.type,
319
+ });
291
320
  }
321
+ break;
292
322
  }
293
323
  }
294
324
  };
@@ -311,7 +341,7 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
311
341
  if (typeof window !== 'undefined') {
312
342
  window.addEventListener('storage', handleStorageEvent);
313
343
  // Store the handler so cleanup can remove it
314
- (socket as any).__oxyStorageHandler = handleStorageEvent;
344
+ socket.__oxyStorageHandler = handleStorageEvent;
315
345
  }
316
346
  });
317
347
 
@@ -320,12 +350,14 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
320
350
  if (authRetryTimer) {
321
351
  clearTimeout(authRetryTimer);
322
352
  }
323
- if (socketRef.current) {
353
+ const currentSocket = socketRef.current;
354
+ if (currentSocket) {
324
355
  // Remove cross-tab storage listener
325
- if (typeof window !== 'undefined' && (socketRef.current as any).__oxyStorageHandler) {
326
- window.removeEventListener('storage', (socketRef.current as any).__oxyStorageHandler);
356
+ const storageHandler = currentSocket.__oxyStorageHandler;
357
+ if (typeof window !== 'undefined' && storageHandler) {
358
+ window.removeEventListener('storage', storageHandler);
327
359
  }
328
- socketRef.current.disconnect();
360
+ currentSocket.disconnect();
329
361
  socketRef.current = null;
330
362
  }
331
363
  };
package/src/index.ts CHANGED
@@ -74,6 +74,10 @@ export {
74
74
  useSecurityInfo,
75
75
  useSecurityActivity,
76
76
  useRecentSecurityActivity,
77
+ useAppData,
78
+ useAppDataNamespace,
79
+ appDataQueryKeys,
80
+ isMissingAppDataEndpointError,
77
81
  } from './hooks/queries';
78
82
 
79
83
  // --- Mutation Hooks ---
@@ -88,6 +92,8 @@ export {
88
92
  useLogoutAll,
89
93
  useUpdateDeviceName,
90
94
  useRemoveDevice,
95
+ useSetAppData,
96
+ useDeleteAppData,
91
97
  } from './hooks/mutations';
92
98
 
93
99
  export {
@@ -104,7 +110,7 @@ export { useWebSSO, isWebBrowser } from './hooks/useWebSSO';
104
110
  export { useSessionSocket } from './hooks/useSessionSocket';
105
111
  export type { UseSessionSocketOptions } from './hooks/useSessionSocket';
106
112
  export { useAssets, setOxyAssetInstance } from './hooks/useAssets';
107
- export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownloadUrl';
113
+ export { useFileDownloadUrl } from './hooks/useFileDownloadUrl';
108
114
  export { useFollow, useFollowerCounts } from './hooks/useFollow';
109
115
  export { useFileFiltering } from './hooks/useFileFiltering';
110
116
  export type { ViewMode, SortBy, SortOrder } from './hooks/useFileFiltering';
@@ -68,7 +68,9 @@ export const mapSessionsToClient = (
68
68
  };
69
69
 
70
70
  /**
71
- * Fetch device sessions with fallback to the legacy session endpoint when needed.
71
+ * Fetch device sessions, falling back to the per-user session endpoint
72
+ * if the device endpoint is unavailable (older API versions or disabled
73
+ * device-grouping feature flag).
72
74
  *
73
75
  * @param oxyServices - Oxy service instance
74
76
  * @param sessionId - Session identifier to fetch
@@ -19,7 +19,8 @@ const MEMORY_STORAGE = (): StorageInterface => {
19
19
 
20
20
  return {
21
21
  async getItem(key: string) {
22
- return store.has(key) ? store.get(key)! : null;
22
+ const value = store.get(key);
23
+ return value === undefined ? null : value;
23
24
  },
24
25
  async setItem(key: string, value: string) {
25
26
  store.set(key, value);
@@ -46,29 +47,33 @@ const createWebStorage = (): StorageInterface => {
46
47
  async getItem(key: string) {
47
48
  try {
48
49
  return window.localStorage.getItem(key);
49
- } catch {
50
+ } catch (err) {
51
+ console.warn('[oxy.storage] localStorage.getItem failed:', err);
50
52
  return null;
51
53
  }
52
54
  },
53
55
  async setItem(key: string, value: string) {
54
56
  try {
55
57
  window.localStorage.setItem(key, value);
56
- } catch {
57
- // Ignore quota or access issues for now.
58
+ } catch (err) {
59
+ // Quota exceeded or storage disabled (e.g., Safari private mode).
60
+ // Surface to logs so it is debuggable, but do not throw so callers
61
+ // can keep functioning with degraded persistence.
62
+ console.warn('[oxy.storage] localStorage.setItem failed:', err);
58
63
  }
59
64
  },
60
65
  async removeItem(key: string) {
61
66
  try {
62
67
  window.localStorage.removeItem(key);
63
- } catch {
64
- // Ignore failures.
68
+ } catch (err) {
69
+ console.warn('[oxy.storage] localStorage.removeItem failed:', err);
65
70
  }
66
71
  },
67
72
  async clear() {
68
73
  try {
69
74
  window.localStorage.clear();
70
- } catch {
71
- // Ignore failures.
75
+ } catch (err) {
76
+ console.warn('[oxy.storage] localStorage.clear failed:', err);
72
77
  }
73
78
  },
74
79
  };
@@ -76,6 +81,31 @@ const createWebStorage = (): StorageInterface => {
76
81
 
77
82
  let asyncStorageInstance: StorageInterface | null = null;
78
83
 
84
+ /**
85
+ * Structural type for the React Native AsyncStorage default export.
86
+ * Only includes the methods this SDK uses.
87
+ */
88
+ interface AsyncStorageLike {
89
+ getItem: (key: string) => Promise<string | null>;
90
+ setItem: (key: string, value: string) => Promise<void>;
91
+ removeItem: (key: string) => Promise<void>;
92
+ clear: () => Promise<void>;
93
+ }
94
+
95
+ /**
96
+ * Type guard verifying that an imported value exposes the AsyncStorage API.
97
+ */
98
+ const isAsyncStorageLike = (value: unknown): value is AsyncStorageLike => {
99
+ if (typeof value !== 'object' || value === null) return false;
100
+ const candidate = value as Record<string, unknown>;
101
+ return (
102
+ typeof candidate.getItem === 'function' &&
103
+ typeof candidate.setItem === 'function' &&
104
+ typeof candidate.removeItem === 'function' &&
105
+ typeof candidate.clear === 'function'
106
+ );
107
+ };
108
+
79
109
  /**
80
110
  * Lazily import React Native AsyncStorage implementation.
81
111
  */
@@ -87,8 +117,17 @@ const createNativeStorage = async (): Promise<StorageInterface> => {
87
117
  try {
88
118
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
89
119
  const moduleName = '@react-native-async-storage/async-storage';
90
- const asyncStorageModule = await import(moduleName);
91
- asyncStorageInstance = asyncStorageModule.default as unknown as StorageInterface;
120
+ const asyncStorageModule = (await import(moduleName)) as { default?: unknown };
121
+ const candidate = asyncStorageModule.default;
122
+ if (!isAsyncStorageLike(candidate)) {
123
+ throw new Error('AsyncStorage default export does not match expected API');
124
+ }
125
+ asyncStorageInstance = {
126
+ getItem: (key) => candidate.getItem(key),
127
+ setItem: (key, value) => candidate.setItem(key, value),
128
+ removeItem: (key) => candidate.removeItem(key),
129
+ clear: () => candidate.clear(),
130
+ };
92
131
  return asyncStorageInstance;
93
132
  } catch (error) {
94
133
  if (process.env.NODE_ENV !== 'production') {