@oxyhq/auth 2.0.4 → 2.0.5
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/WebOxyProvider.js +37 -0
- package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
- package/dist/cjs/hooks/queryClient.js +136 -92
- package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
- package/dist/cjs/hooks/useSessionSocket.js +81 -94
- package/dist/cjs/index.js +1 -2
- package/dist/cjs/utils/sessionHelpers.js +3 -1
- package/dist/cjs/utils/storageHelpers.js +36 -10
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/WebOxyProvider.js +38 -1
- package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
- package/dist/esm/hooks/queryClient.js +132 -89
- package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
- package/dist/esm/hooks/useSessionSocket.js +81 -94
- package/dist/esm/index.js +1 -1
- package/dist/esm/utils/sessionHelpers.js +3 -1
- package/dist/esm/utils/storageHelpers.js +36 -10
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/WebOxyProvider.d.ts +1 -1
- package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
- package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
- package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
- package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
- package/dist/types/hooks/queryClient.d.ts +24 -10
- package/dist/types/hooks/useAssets.d.ts +1 -1
- package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
- package/dist/types/index.d.ts +1 -1
- package/dist/types/utils/sessionHelpers.d.ts +3 -1
- package/package.json +22 -3
- package/src/WebOxyProvider.tsx +39 -1
- package/src/hooks/mutations/useAccountMutations.ts +230 -57
- package/src/hooks/queryClient.ts +140 -83
- package/src/hooks/useFileDownloadUrl.ts +15 -39
- package/src/hooks/useSessionSocket.ts +123 -91
- package/src/index.ts +1 -1
- package/src/utils/sessionHelpers.ts +3 -1
- package/src/utils/storageHelpers.ts +49 -10
package/src/hooks/queryClient.ts
CHANGED
|
@@ -1,112 +1,169 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Web QueryClient with offline-first defaults + localStorage persistence.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the persistence behaviour in `@oxyhq/services/queryClient` so
|
|
5
|
+
* web auth apps (FedCM, popup, redirect flows) survive a page reload with
|
|
6
|
+
* cached identity + paused mutations intact.
|
|
7
|
+
*
|
|
8
|
+
* Persistence is opt-in via `attachQueryPersistence(...)` so SSR callers
|
|
9
|
+
* (Next.js getServerSideProps, Vite SSR, tests) can create a stateless
|
|
10
|
+
* client without touching `window`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { QueryClient, type Mutation, type Query } from '@tanstack/react-query';
|
|
14
|
+
import {
|
|
15
|
+
persistQueryClient,
|
|
16
|
+
type PersistedClient,
|
|
17
|
+
} from '@tanstack/react-query-persist-client';
|
|
18
|
+
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
|
2
19
|
import type { StorageInterface } from '../utils/storageHelpers';
|
|
3
20
|
|
|
4
|
-
const QUERY_CACHE_KEY = '
|
|
5
|
-
const
|
|
21
|
+
const QUERY_CACHE_KEY = 'oxy_auth_query_cache_v2';
|
|
22
|
+
const QUERY_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const QUERY_PERSIST_THROTTLE_MS = 1_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Query-key prefixes whose data is safe to restore across reloads.
|
|
27
|
+
* Web auth surfaces are session/profile heavy — lists and history are not
|
|
28
|
+
* persisted to keep the localStorage footprint small.
|
|
29
|
+
*/
|
|
30
|
+
const PERSISTED_QUERY_PREFIXES: ReadonlyArray<string> = [
|
|
31
|
+
'accounts',
|
|
32
|
+
'users',
|
|
33
|
+
'sessions',
|
|
34
|
+
'auth',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function shouldDehydrateQuery(query: Query): boolean {
|
|
38
|
+
if (query.state.status !== 'success') return false;
|
|
39
|
+
const head = query.queryKey[0];
|
|
40
|
+
return typeof head === 'string' && PERSISTED_QUERY_PREFIXES.includes(head);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function shouldDehydrateMutation(_mutation: Mutation): boolean {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
6
46
|
|
|
7
47
|
/**
|
|
8
|
-
*
|
|
48
|
+
* Best-effort detection — works in browsers, Node SSR, and React Server
|
|
49
|
+
* Components. `localStorage` is gated behind `window` because Node and edge
|
|
50
|
+
* runtimes may polyfill `globalThis.localStorage` inconsistently.
|
|
9
51
|
*/
|
|
10
|
-
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
function getBrowserLocalStorage(): Storage | null {
|
|
53
|
+
if (typeof window === 'undefined') return null;
|
|
54
|
+
try {
|
|
55
|
+
if (!window.localStorage) return null;
|
|
56
|
+
return window.localStorage;
|
|
57
|
+
} catch {
|
|
58
|
+
// Access blocked (Safari Private Mode, sandboxed iframe, etc.)
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const createPersistenceAdapter = (storage: StorageInterface) => ({
|
|
64
|
+
persistClient: async (client: unknown): Promise<void> => {
|
|
65
|
+
try {
|
|
66
|
+
await storage.setItem(QUERY_CACHE_KEY, JSON.stringify(client));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
69
|
+
console.warn('[QueryClient] Failed to persist cache', error);
|
|
24
70
|
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (parsed.version !== QUERY_CACHE_VERSION) {
|
|
35
|
-
// Clear old cache on version mismatch
|
|
36
|
-
await storage.removeItem(QUERY_CACHE_KEY);
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Check if cache is too old (30 days)
|
|
41
|
-
const maxAge = 30 * 24 * 60 * 60 * 1000;
|
|
42
|
-
if (parsed.timestamp && Date.now() - parsed.timestamp > maxAge) {
|
|
43
|
-
await storage.removeItem(QUERY_CACHE_KEY);
|
|
44
|
-
return undefined;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return parsed.clientState;
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
50
|
-
console.warn('[QueryClient] Failed to restore cache:', error);
|
|
51
|
-
}
|
|
52
|
-
return undefined;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
restoreClient: async (): Promise<unknown> => {
|
|
74
|
+
try {
|
|
75
|
+
const cached = await storage.getItem(QUERY_CACHE_KEY);
|
|
76
|
+
return cached ? JSON.parse(cached) : undefined;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
79
|
+
console.warn('[QueryClient] Failed to restore cache', error);
|
|
53
80
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
removeClient: async (): Promise<void> => {
|
|
85
|
+
try {
|
|
86
|
+
await storage.removeItem(QUERY_CACHE_KEY);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
89
|
+
console.warn('[QueryClient] Failed to remove cache', error);
|
|
62
90
|
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
};
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
66
94
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
*/
|
|
70
|
-
export const createQueryClient = (storage?: StorageInterface | null): QueryClient => {
|
|
71
|
-
const client = new QueryClient({
|
|
95
|
+
export const createQueryClient = (): QueryClient =>
|
|
96
|
+
new QueryClient({
|
|
72
97
|
defaultOptions: {
|
|
73
98
|
queries: {
|
|
74
|
-
// Data is fresh for 5 minutes
|
|
75
99
|
staleTime: 5 * 60 * 1000,
|
|
76
|
-
|
|
77
|
-
gcTime: 10 * 60 * 1000,
|
|
78
|
-
// Retry 3 times with exponential backoff
|
|
100
|
+
gcTime: 30 * 60 * 1000,
|
|
79
101
|
retry: 3,
|
|
80
|
-
retryDelay: (
|
|
81
|
-
// Refetch on reconnect
|
|
102
|
+
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
|
|
82
103
|
refetchOnReconnect: true,
|
|
83
|
-
// Don't refetch on window focus (better for mobile)
|
|
84
104
|
refetchOnWindowFocus: false,
|
|
85
|
-
// Offline-first: use cache when offline
|
|
86
105
|
networkMode: 'offlineFirst',
|
|
87
106
|
},
|
|
88
107
|
mutations: {
|
|
89
|
-
// Retry once for mutations
|
|
90
108
|
retry: 1,
|
|
91
|
-
// Offline-first: queue mutations when offline
|
|
92
109
|
networkMode: 'offlineFirst',
|
|
93
110
|
},
|
|
94
111
|
},
|
|
95
112
|
});
|
|
96
113
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return client;
|
|
103
|
-
};
|
|
114
|
+
export interface AttachPersistenceResult {
|
|
115
|
+
restored: Promise<void>;
|
|
116
|
+
unsubscribe: () => void;
|
|
117
|
+
}
|
|
104
118
|
|
|
105
119
|
/**
|
|
106
|
-
*
|
|
120
|
+
* Wire `persistQueryClient` to browser `localStorage` (or a no-op when not
|
|
121
|
+
* in a browser). Returns the restore promise so consumers can `await` it
|
|
122
|
+
* before exposing the client to <Suspense> boundaries.
|
|
107
123
|
*/
|
|
108
|
-
export const
|
|
109
|
-
|
|
110
|
-
|
|
124
|
+
export const attachQueryPersistence = (
|
|
125
|
+
queryClient: QueryClient,
|
|
126
|
+
): AttachPersistenceResult => {
|
|
127
|
+
const localStorage = getBrowserLocalStorage();
|
|
128
|
+
if (!localStorage) {
|
|
129
|
+
return { restored: Promise.resolve(), unsubscribe: () => {} };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const persister = createSyncStoragePersister({
|
|
133
|
+
storage: localStorage,
|
|
134
|
+
key: QUERY_CACHE_KEY,
|
|
135
|
+
throttleTime: QUERY_PERSIST_THROTTLE_MS,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const [unsubscribe, restored] = persistQueryClient({
|
|
139
|
+
queryClient,
|
|
140
|
+
persister,
|
|
141
|
+
maxAge: QUERY_CACHE_MAX_AGE,
|
|
142
|
+
dehydrateOptions: {
|
|
143
|
+
shouldDehydrateQuery,
|
|
144
|
+
shouldDehydrateMutation,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
restored.catch((error) => {
|
|
149
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
150
|
+
console.warn('[QueryClient] Failed to restore persisted cache', error);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { unsubscribe, restored };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const clearQueryCache = async (
|
|
158
|
+
storage: StorageInterface,
|
|
159
|
+
): Promise<void> => {
|
|
160
|
+
try {
|
|
161
|
+
await storage.removeItem(QUERY_CACHE_KEY);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
164
|
+
console.warn('[QueryClient] Failed to remove cache', error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
111
167
|
};
|
|
112
168
|
|
|
169
|
+
export type { PersistedClient };
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { OxyServices } from '@oxyhq/core';
|
|
3
|
-
|
|
4
|
-
let oxyInstance: OxyServices | null = null;
|
|
5
|
-
|
|
6
|
-
export const setOxyFileUrlInstance = (instance: OxyServices) => {
|
|
7
|
-
oxyInstance = instance;
|
|
8
|
-
};
|
|
2
|
+
import type { OxyServices } from '@oxyhq/core';
|
|
9
3
|
|
|
10
4
|
export interface UseFileDownloadUrlOptions {
|
|
11
5
|
variant?: string;
|
|
@@ -21,38 +15,21 @@ export interface UseFileDownloadUrlResult {
|
|
|
21
15
|
/**
|
|
22
16
|
* Hook to resolve a file's download URL asynchronously.
|
|
23
17
|
*
|
|
24
|
-
* Prefers the provided `oxyServices` instance, falls back to the module-level
|
|
25
|
-
* singleton set via `setOxyFileUrlInstance`.
|
|
26
|
-
*
|
|
27
18
|
* Uses `getFileDownloadUrlAsync` first, falling back to the synchronous
|
|
28
19
|
* `getFileDownloadUrl` if the async call fails.
|
|
29
20
|
*/
|
|
30
21
|
export const useFileDownloadUrl = (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
oxyServices: OxyServices | null | undefined,
|
|
23
|
+
fileId: string | null | undefined,
|
|
24
|
+
options?: UseFileDownloadUrlOptions,
|
|
34
25
|
): UseFileDownloadUrlResult => {
|
|
35
|
-
// Support two call signatures:
|
|
36
|
-
// 1. useFileDownloadUrl(oxyServices, fileId, options) — preferred
|
|
37
|
-
// 2. useFileDownloadUrl(fileId, options) — legacy (uses singleton)
|
|
38
|
-
let services: OxyServices | null;
|
|
39
|
-
let fileId: string | null | undefined;
|
|
40
|
-
let options: UseFileDownloadUrlOptions | undefined;
|
|
41
|
-
|
|
42
|
-
if (fileIdOrServices instanceof OxyServices) {
|
|
43
|
-
services = fileIdOrServices;
|
|
44
|
-
fileId = typeof fileIdOrOptions === 'string' ? fileIdOrOptions : null;
|
|
45
|
-
options = maybeOptions;
|
|
46
|
-
} else {
|
|
47
|
-
services = oxyInstance;
|
|
48
|
-
fileId = typeof fileIdOrServices === 'string' ? fileIdOrServices : null;
|
|
49
|
-
options = typeof fileIdOrOptions === 'object' && fileIdOrOptions !== null ? fileIdOrOptions as UseFileDownloadUrlOptions : undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
26
|
const [url, setUrl] = useState<string | null>(null);
|
|
53
27
|
const [loading, setLoading] = useState(false);
|
|
54
28
|
const [error, setError] = useState<Error | null>(null);
|
|
55
29
|
|
|
30
|
+
const variant = options?.variant;
|
|
31
|
+
const expiresIn = options?.expiresIn;
|
|
32
|
+
|
|
56
33
|
useEffect(() => {
|
|
57
34
|
if (!fileId) {
|
|
58
35
|
setUrl(null);
|
|
@@ -61,7 +38,7 @@ export const useFileDownloadUrl = (
|
|
|
61
38
|
return;
|
|
62
39
|
}
|
|
63
40
|
|
|
64
|
-
if (!
|
|
41
|
+
if (!oxyServices) {
|
|
65
42
|
setUrl(null);
|
|
66
43
|
setLoading(false);
|
|
67
44
|
setError(new Error('OxyServices instance not configured for useFileDownloadUrl'));
|
|
@@ -69,22 +46,22 @@ export const useFileDownloadUrl = (
|
|
|
69
46
|
}
|
|
70
47
|
|
|
71
48
|
let cancelled = false;
|
|
72
|
-
const instance =
|
|
49
|
+
const instance = oxyServices;
|
|
50
|
+
const targetFileId = fileId;
|
|
73
51
|
|
|
74
52
|
const load = async () => {
|
|
75
53
|
setLoading(true);
|
|
76
54
|
setError(null);
|
|
77
55
|
|
|
78
56
|
try {
|
|
79
|
-
const { variant, expiresIn } = options || {};
|
|
80
57
|
let resolvedUrl: string | null = null;
|
|
81
58
|
|
|
82
59
|
if (typeof instance.getFileDownloadUrlAsync === 'function') {
|
|
83
|
-
resolvedUrl = await instance.getFileDownloadUrlAsync(
|
|
60
|
+
resolvedUrl = await instance.getFileDownloadUrlAsync(targetFileId, variant, expiresIn);
|
|
84
61
|
}
|
|
85
62
|
|
|
86
63
|
if (!resolvedUrl && typeof instance.getFileDownloadUrl === 'function') {
|
|
87
|
-
resolvedUrl = instance.getFileDownloadUrl(
|
|
64
|
+
resolvedUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
|
|
88
65
|
}
|
|
89
66
|
|
|
90
67
|
if (!cancelled) {
|
|
@@ -94,8 +71,7 @@ export const useFileDownloadUrl = (
|
|
|
94
71
|
// Fallback to sync URL on error where possible
|
|
95
72
|
try {
|
|
96
73
|
if (typeof instance.getFileDownloadUrl === 'function') {
|
|
97
|
-
const
|
|
98
|
-
const fallbackUrl = instance.getFileDownloadUrl(fileId!, variant, expiresIn);
|
|
74
|
+
const fallbackUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
|
|
99
75
|
if (!cancelled) {
|
|
100
76
|
setUrl(fallbackUrl || null);
|
|
101
77
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
@@ -103,7 +79,7 @@ export const useFileDownloadUrl = (
|
|
|
103
79
|
return;
|
|
104
80
|
}
|
|
105
81
|
} catch {
|
|
106
|
-
//
|
|
82
|
+
// Secondary failure: surface the original error below.
|
|
107
83
|
}
|
|
108
84
|
|
|
109
85
|
if (!cancelled) {
|
|
@@ -121,7 +97,7 @@ export const useFileDownloadUrl = (
|
|
|
121
97
|
return () => {
|
|
122
98
|
cancelled = true;
|
|
123
99
|
};
|
|
124
|
-
}, [fileId,
|
|
100
|
+
}, [fileId, oxyServices, variant, expiresIn]);
|
|
125
101
|
|
|
126
102
|
return { url, loading, error };
|
|
127
103
|
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
await
|
|
218
|
-
}
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
refreshSessions();
|
|
272
|
+
break;
|
|
225
273
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
refreshSessions();
|
|
288
|
+
break;
|
|
250
289
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
303
|
+
refreshSessions();
|
|
265
304
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
+
const currentSocket = socketRef.current;
|
|
354
|
+
if (currentSocket) {
|
|
324
355
|
// Remove cross-tab storage listener
|
|
325
|
-
|
|
326
|
-
|
|
356
|
+
const storageHandler = currentSocket.__oxyStorageHandler;
|
|
357
|
+
if (typeof window !== 'undefined' && storageHandler) {
|
|
358
|
+
window.removeEventListener('storage', storageHandler);
|
|
327
359
|
}
|
|
328
|
-
|
|
360
|
+
currentSocket.disconnect();
|
|
329
361
|
socketRef.current = null;
|
|
330
362
|
}
|
|
331
363
|
};
|