@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/WebOxyProvider.js +37 -0
- package/dist/cjs/hooks/mutations/index.js +5 -1
- package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
- package/dist/cjs/hooks/mutations/useAppData.js +133 -0
- package/dist/cjs/hooks/queries/appDataQueryKeys.js +46 -0
- package/dist/cjs/hooks/queries/index.js +8 -1
- package/dist/cjs/hooks/queries/useAppData.js +87 -0
- 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 +8 -3
- 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/index.js +2 -0
- package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
- package/dist/esm/hooks/mutations/useAppData.js +128 -0
- package/dist/esm/hooks/queries/appDataQueryKeys.js +42 -0
- package/dist/esm/hooks/queries/index.js +3 -0
- package/dist/esm/hooks/queries/useAppData.js +82 -0
- 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 +3 -3
- 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/index.d.ts +1 -0
- package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
- package/dist/types/hooks/mutations/useAppData.d.ts +47 -0
- package/dist/types/hooks/queries/appDataQueryKeys.d.ts +24 -0
- package/dist/types/hooks/queries/index.d.ts +2 -0
- package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
- package/dist/types/hooks/queries/useAppData.d.ts +46 -0
- 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 +3 -3
- package/dist/types/utils/sessionHelpers.d.ts +3 -1
- package/package.json +22 -3
- package/src/WebOxyProvider.tsx +39 -1
- package/src/hooks/mutations/index.ts +3 -0
- package/src/hooks/mutations/useAccountMutations.ts +230 -57
- package/src/hooks/mutations/useAppData.ts +167 -0
- package/src/hooks/queries/appDataQueryKeys.ts +53 -0
- package/src/hooks/queries/index.ts +4 -0
- package/src/hooks/queries/useAppData.ts +105 -0
- 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 +7 -1
- package/src/utils/sessionHelpers.ts +3 -1
- package/src/utils/storageHelpers.ts +49 -10
|
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
9
9
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
10
10
|
import { OxyServices, CrossDomainAuth, createAuthManager, } from '@oxyhq/core';
|
|
11
11
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
12
|
-
import { createQueryClient } from './hooks/queryClient';
|
|
12
|
+
import { attachQueryPersistence, createQueryClient } from './hooks/queryClient';
|
|
13
13
|
const WebOxyContext = createContext(null);
|
|
14
14
|
/**
|
|
15
15
|
* Web-only Oxy Provider
|
|
@@ -35,6 +35,29 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
|
|
|
35
35
|
const [crossDomainAuth] = useState(() => new CrossDomainAuth(oxyServices));
|
|
36
36
|
const [authManager] = useState(() => createAuthManager(oxyServices, { autoRefresh: true }));
|
|
37
37
|
const [queryClient] = useState(() => createQueryClient());
|
|
38
|
+
// Block first render until the persisted localStorage cache has been
|
|
39
|
+
// restored — mirrors the RN OxyProvider pattern. Without this gate the
|
|
40
|
+
// first paint observes an empty cache and any consumer reading
|
|
41
|
+
// `getQueryData(...)` synchronously (or using `placeholderData: 'previous'`
|
|
42
|
+
// gating) misses the persisted blob.
|
|
43
|
+
//
|
|
44
|
+
// Persistence is attached inside the same effect so we can hold a
|
|
45
|
+
// reference to the `restored` promise and only flip `isRestoring` to
|
|
46
|
+
// false once it settles (success OR failure). Detach on unmount so HMR
|
|
47
|
+
// doesn't leak subscriptions.
|
|
48
|
+
const [isRestoring, setIsRestoring] = useState(true);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let mounted = true;
|
|
51
|
+
const { restored, unsubscribe } = attachQueryPersistence(queryClient);
|
|
52
|
+
restored.finally(() => {
|
|
53
|
+
if (mounted)
|
|
54
|
+
setIsRestoring(false);
|
|
55
|
+
});
|
|
56
|
+
return () => {
|
|
57
|
+
mounted = false;
|
|
58
|
+
unsubscribe();
|
|
59
|
+
};
|
|
60
|
+
}, [queryClient]);
|
|
38
61
|
// Auth state
|
|
39
62
|
const [user, setUser] = useState(null);
|
|
40
63
|
const [isLoading, setIsLoading] = useState(!skipAutoCheck);
|
|
@@ -253,6 +276,20 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
|
|
|
253
276
|
signIn, signInWithFedCM, signInWithPopup, signInWithRedirect,
|
|
254
277
|
signOut, isFedCMSupported, switchSession, clearSessionState,
|
|
255
278
|
]);
|
|
279
|
+
// Mirror the RN OxyProvider pattern: don't expose the QueryClient (or
|
|
280
|
+
// mount children) until the persisted cache has been restored. On the
|
|
281
|
+
// web this prevents the first paint from observing an empty
|
|
282
|
+
// localStorage-backed cache, which would otherwise force every
|
|
283
|
+
// identity/session/auth query to refetch from the network even when a
|
|
284
|
+
// fresh blob was available on disk.
|
|
285
|
+
//
|
|
286
|
+
// The restored promise is wired with `.finally(...)` upstream, so this
|
|
287
|
+
// unblocks on both success and failure within typically <50ms (sync
|
|
288
|
+
// localStorage read + JSON.parse). A safety net is unnecessary: the
|
|
289
|
+
// restore promise always settles synchronously after one microtask.
|
|
290
|
+
if (isRestoring) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
256
293
|
return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(WebOxyContext.Provider, { value: contextValue, children: children }) }));
|
|
257
294
|
}
|
|
258
295
|
/**
|
|
@@ -8,3 +8,5 @@
|
|
|
8
8
|
export { useUpdateProfile, useUploadAvatar, useUpdateAccountSettings, useUpdatePrivacySettings, useUploadFile, } from './useAccountMutations';
|
|
9
9
|
// Service mutation hooks (sessions, devices)
|
|
10
10
|
export { useSwitchSession, useLogoutSession, useLogoutAll, useUpdateDeviceName, useRemoveDevice, } from './useServicesMutations';
|
|
11
|
+
// App-data KV store mutation hooks
|
|
12
|
+
export { useSetAppData, useDeleteAppData } from './useAppData';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { authenticatedApiCall } from '@oxyhq/core';
|
|
3
|
-
import { queryKeys, invalidateAccountQueries, invalidateUserQueries } from '../queries/queryKeys';
|
|
3
|
+
import { queryKeys, invalidateAccountQueries, invalidateUserQueries, invalidateSessionQueries } from '../queries/queryKeys';
|
|
4
4
|
import { useWebOxy } from '../../WebOxyProvider';
|
|
5
5
|
import { toast } from 'sonner';
|
|
6
6
|
import { refreshAvatarInStore } from '../../utils/avatarUtils';
|
|
@@ -9,7 +9,7 @@ import { useAuthStore } from '../../stores/authStore';
|
|
|
9
9
|
* Update user profile with optimistic updates and offline queue support
|
|
10
10
|
*/
|
|
11
11
|
export const useUpdateProfile = () => {
|
|
12
|
-
const { oxyServices, activeSessionId
|
|
12
|
+
const { oxyServices, activeSessionId } = useWebOxy();
|
|
13
13
|
const queryClient = useQueryClient();
|
|
14
14
|
return useMutation({
|
|
15
15
|
mutationFn: async (updates) => {
|
|
@@ -37,12 +37,30 @@ export const useUpdateProfile = () => {
|
|
|
37
37
|
}
|
|
38
38
|
return { previousUser };
|
|
39
39
|
},
|
|
40
|
-
// On error, rollback
|
|
40
|
+
// On error, rollback ONLY the keys this mutation tried to change
|
|
41
41
|
onError: (error, updates, context) => {
|
|
42
|
-
if (context?.previousUser) {
|
|
43
|
-
|
|
42
|
+
if (context?.previousUser && updates) {
|
|
43
|
+
const previousUser = context.previousUser;
|
|
44
|
+
const changedKeys = Object.keys(updates);
|
|
45
|
+
const partialRollback = changedKeys.reduce((acc, key) => {
|
|
46
|
+
acc[key] = previousUser[key];
|
|
47
|
+
return acc;
|
|
48
|
+
}, {});
|
|
49
|
+
const current = queryClient.getQueryData(queryKeys.accounts.current());
|
|
50
|
+
if (current) {
|
|
51
|
+
queryClient.setQueryData(queryKeys.accounts.current(), {
|
|
52
|
+
...current,
|
|
53
|
+
...partialRollback,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
44
56
|
if (activeSessionId) {
|
|
45
|
-
queryClient.
|
|
57
|
+
const currentProfile = queryClient.getQueryData(queryKeys.users.profile(activeSessionId));
|
|
58
|
+
if (currentProfile) {
|
|
59
|
+
queryClient.setQueryData(queryKeys.users.profile(activeSessionId), {
|
|
60
|
+
...currentProfile,
|
|
61
|
+
...partialRollback,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
66
|
toast.error(error instanceof Error ? error.message : 'Failed to update profile');
|
|
@@ -60,9 +78,13 @@ export const useUpdateProfile = () => {
|
|
|
60
78
|
if (updates.avatar && activeSessionId && oxyServices) {
|
|
61
79
|
refreshAvatarInStore(activeSessionId, updates.avatar, oxyServices);
|
|
62
80
|
}
|
|
63
|
-
// Invalidate all related queries
|
|
81
|
+
// Invalidate all related queries so every consumer (AccountSwitcher,
|
|
82
|
+
// session lists, managed accounts, etc.) refetches the fresh profile.
|
|
83
|
+
// Critical right after `username` is set the first time, when every
|
|
84
|
+
// cached "session profile" still reports the user as unnamed.
|
|
64
85
|
invalidateUserQueries(queryClient);
|
|
65
86
|
invalidateAccountQueries(queryClient);
|
|
87
|
+
invalidateSessionQueries(queryClient);
|
|
66
88
|
},
|
|
67
89
|
});
|
|
68
90
|
};
|
|
@@ -102,11 +124,25 @@ export const useUploadAvatar = () => {
|
|
|
102
124
|
}
|
|
103
125
|
return { previousUser };
|
|
104
126
|
},
|
|
105
|
-
onError: (error,
|
|
127
|
+
onError: (error, _file, context) => {
|
|
128
|
+
// Avatar upload only mutates the `avatar` field — restore only that key
|
|
106
129
|
if (context?.previousUser) {
|
|
107
|
-
|
|
130
|
+
const previousAvatar = context.previousUser.avatar;
|
|
131
|
+
const current = queryClient.getQueryData(queryKeys.accounts.current());
|
|
132
|
+
if (current) {
|
|
133
|
+
queryClient.setQueryData(queryKeys.accounts.current(), {
|
|
134
|
+
...current,
|
|
135
|
+
avatar: previousAvatar,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
108
138
|
if (activeSessionId) {
|
|
109
|
-
queryClient.
|
|
139
|
+
const currentProfile = queryClient.getQueryData(queryKeys.users.profile(activeSessionId));
|
|
140
|
+
if (currentProfile) {
|
|
141
|
+
queryClient.setQueryData(queryKeys.users.profile(activeSessionId), {
|
|
142
|
+
...currentProfile,
|
|
143
|
+
avatar: previousAvatar,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
110
146
|
}
|
|
111
147
|
}
|
|
112
148
|
toast.error(error instanceof Error ? error.message : 'Failed to upload avatar');
|
|
@@ -122,24 +158,46 @@ export const useUploadAvatar = () => {
|
|
|
122
158
|
if (data?.avatar && activeSessionId && oxyServices) {
|
|
123
159
|
refreshAvatarInStore(activeSessionId, data.avatar, oxyServices);
|
|
124
160
|
}
|
|
125
|
-
// Invalidate all related queries to refresh everywhere
|
|
161
|
+
// Invalidate all related queries to refresh everywhere, including the
|
|
162
|
+
// sessions cache so other-account avatars update too.
|
|
126
163
|
invalidateUserQueries(queryClient);
|
|
127
164
|
invalidateAccountQueries(queryClient);
|
|
165
|
+
invalidateSessionQueries(queryClient);
|
|
128
166
|
toast.success('Avatar updated successfully');
|
|
129
167
|
},
|
|
130
168
|
});
|
|
131
169
|
};
|
|
132
170
|
/**
|
|
133
|
-
* Update account settings
|
|
171
|
+
* Update account settings (privacy preferences).
|
|
172
|
+
*
|
|
173
|
+
* Privacy settings are not part of the `PUT /users/me` allow-list; the API
|
|
174
|
+
* would silently drop them. Route through `updatePrivacySettings` so the
|
|
175
|
+
* dedicated `PATCH /privacy/:id/privacy` endpoint performs a dot-path merge
|
|
176
|
+
* and returns the updated `privacySettings` object.
|
|
177
|
+
*
|
|
178
|
+
* The returned object exposes the standard mutation surface PLUS a
|
|
179
|
+
* convenience `mutate(updates)` / `mutateAsync(updates)` that snapshots
|
|
180
|
+
* the current user from `useWebOxy()` at dispatch time.
|
|
134
181
|
*/
|
|
135
182
|
export const useUpdateAccountSettings = () => {
|
|
136
|
-
const { oxyServices, activeSessionId } = useWebOxy();
|
|
183
|
+
const { oxyServices, activeSessionId, user } = useWebOxy();
|
|
137
184
|
const queryClient = useQueryClient();
|
|
138
|
-
|
|
139
|
-
mutationFn: async (
|
|
140
|
-
|
|
185
|
+
const mutation = useMutation({
|
|
186
|
+
mutationFn: async ({ updates, currentUser }) => {
|
|
187
|
+
const userId = currentUser.id;
|
|
188
|
+
if (!userId) {
|
|
189
|
+
throw new Error('User ID is required to update account settings');
|
|
190
|
+
}
|
|
191
|
+
const updatedPrivacy = await authenticatedApiCall(oxyServices, activeSessionId, () => oxyServices.updatePrivacySettings(updates, userId));
|
|
192
|
+
// Rebuild against the dispatch-time snapshot, NOT the live cache.
|
|
193
|
+
// The cache may have been mutated by a sibling write between
|
|
194
|
+
// dispatch and settle.
|
|
195
|
+
return {
|
|
196
|
+
...currentUser,
|
|
197
|
+
privacySettings: updatedPrivacy,
|
|
198
|
+
};
|
|
141
199
|
},
|
|
142
|
-
onMutate: async (
|
|
200
|
+
onMutate: async ({ updates }) => {
|
|
143
201
|
await queryClient.cancelQueries({ queryKey: queryKeys.accounts.settings() });
|
|
144
202
|
const previousUser = queryClient.getQueryData(queryKeys.accounts.current());
|
|
145
203
|
if (previousUser) {
|
|
@@ -147,15 +205,31 @@ export const useUpdateAccountSettings = () => {
|
|
|
147
205
|
...previousUser,
|
|
148
206
|
privacySettings: {
|
|
149
207
|
...previousUser.privacySettings,
|
|
150
|
-
...
|
|
208
|
+
...updates,
|
|
151
209
|
},
|
|
152
210
|
});
|
|
153
211
|
}
|
|
154
212
|
return { previousUser };
|
|
155
213
|
},
|
|
156
|
-
onError: (error,
|
|
157
|
-
|
|
158
|
-
|
|
214
|
+
onError: (error, { updates }, context) => {
|
|
215
|
+
// Restore only the privacySettings keys this mutation tried to change
|
|
216
|
+
if (context?.previousUser && updates) {
|
|
217
|
+
const previousPrivacy = context.previousUser.privacySettings ?? {};
|
|
218
|
+
const changedKeys = Object.keys(updates);
|
|
219
|
+
const partialPrivacyRollback = changedKeys.reduce((acc, key) => {
|
|
220
|
+
acc[key] = previousPrivacy[key];
|
|
221
|
+
return acc;
|
|
222
|
+
}, {});
|
|
223
|
+
const current = queryClient.getQueryData(queryKeys.accounts.current());
|
|
224
|
+
if (current) {
|
|
225
|
+
queryClient.setQueryData(queryKeys.accounts.current(), {
|
|
226
|
+
...current,
|
|
227
|
+
privacySettings: {
|
|
228
|
+
...(current.privacySettings ?? {}),
|
|
229
|
+
...partialPrivacyRollback,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
159
233
|
}
|
|
160
234
|
toast.error(error instanceof Error ? error.message : 'Failed to update settings');
|
|
161
235
|
},
|
|
@@ -170,6 +244,26 @@ export const useUpdateAccountSettings = () => {
|
|
|
170
244
|
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.settings() });
|
|
171
245
|
},
|
|
172
246
|
});
|
|
247
|
+
// Wrap mutate/mutateAsync so call sites pass a plain settings object and
|
|
248
|
+
// the current user is captured at dispatch time.
|
|
249
|
+
return {
|
|
250
|
+
...mutation,
|
|
251
|
+
mutate: (updates) => {
|
|
252
|
+
const currentUser = user ?? queryClient.getQueryData(queryKeys.accounts.current());
|
|
253
|
+
if (!currentUser) {
|
|
254
|
+
toast.error('Cannot update account settings: no current user');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
mutation.mutate({ updates, currentUser });
|
|
258
|
+
},
|
|
259
|
+
mutateAsync: async (updates) => {
|
|
260
|
+
const currentUser = user ?? queryClient.getQueryData(queryKeys.accounts.current());
|
|
261
|
+
if (!currentUser) {
|
|
262
|
+
throw new Error('Cannot update account settings: no current user');
|
|
263
|
+
}
|
|
264
|
+
return mutation.mutateAsync({ updates, currentUser });
|
|
265
|
+
},
|
|
266
|
+
};
|
|
173
267
|
};
|
|
174
268
|
/**
|
|
175
269
|
* Update privacy settings with optimistic updates and authentication handling
|
|
@@ -215,43 +309,91 @@ export const useUpdatePrivacySettings = () => {
|
|
|
215
309
|
}
|
|
216
310
|
return { previousPrivacySettings, previousUser };
|
|
217
311
|
},
|
|
218
|
-
// On error, rollback
|
|
219
|
-
|
|
312
|
+
// On error, rollback ONLY the privacy keys this mutation tried to change.
|
|
313
|
+
// Restoring the entire previous object would wipe out other concurrent
|
|
314
|
+
// optimistic updates (e.g. user toggles two privacy switches in quick
|
|
315
|
+
// succession; failure on one must not revert the other).
|
|
316
|
+
onError: (error, { settings, userId }, context) => {
|
|
220
317
|
const targetUserId = userId || user?.id;
|
|
221
|
-
|
|
222
|
-
|
|
318
|
+
const changedKeys = settings ? Object.keys(settings) : [];
|
|
319
|
+
// Rollback the privacy.settings query (partial)
|
|
320
|
+
if (context?.previousPrivacySettings && targetUserId && changedKeys.length > 0) {
|
|
321
|
+
const previousPrivacy = context.previousPrivacySettings;
|
|
322
|
+
const partialPrivacyRollback = changedKeys.reduce((acc, key) => {
|
|
323
|
+
acc[key] = previousPrivacy[key];
|
|
324
|
+
return acc;
|
|
325
|
+
}, {});
|
|
326
|
+
const currentPrivacy = queryClient.getQueryData(queryKeys.privacy.settings(targetUserId));
|
|
327
|
+
if (currentPrivacy) {
|
|
328
|
+
queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), {
|
|
329
|
+
...currentPrivacy,
|
|
330
|
+
...partialPrivacyRollback,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
223
333
|
}
|
|
224
|
-
|
|
225
|
-
|
|
334
|
+
// Rollback the accounts.current() user.privacySettings (partial)
|
|
335
|
+
if (context?.previousUser && changedKeys.length > 0) {
|
|
336
|
+
const previousPrivacy = (context.previousUser.privacySettings ?? {});
|
|
337
|
+
const partialPrivacyRollback = changedKeys.reduce((acc, key) => {
|
|
338
|
+
acc[key] = previousPrivacy[key];
|
|
339
|
+
return acc;
|
|
340
|
+
}, {});
|
|
341
|
+
const current = queryClient.getQueryData(queryKeys.accounts.current());
|
|
342
|
+
if (current) {
|
|
343
|
+
queryClient.setQueryData(queryKeys.accounts.current(), {
|
|
344
|
+
...current,
|
|
345
|
+
privacySettings: {
|
|
346
|
+
...(current.privacySettings ?? {}),
|
|
347
|
+
...partialPrivacyRollback,
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// After partial rollback, reconcile against the server so the cache
|
|
353
|
+
// converges to the authoritative state for the failed keys.
|
|
354
|
+
if (targetUserId) {
|
|
355
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.privacy.settings(targetUserId) });
|
|
226
356
|
}
|
|
227
357
|
toast.error(error instanceof Error ? error.message : 'Failed to update privacy settings');
|
|
228
358
|
},
|
|
229
|
-
// On success,
|
|
230
|
-
|
|
359
|
+
// On success, MERGE the server response into the cached state. Older
|
|
360
|
+
// API builds returned only the changed field (or wiped the privacySettings
|
|
361
|
+
// subdocument when handed a partial update), which would clobber every
|
|
362
|
+
// other toggle if we blindly replaced. Defensive merge means the UI stays
|
|
363
|
+
// consistent regardless of server behaviour.
|
|
364
|
+
//
|
|
365
|
+
// BOTH the privacy.settings query AND the accounts.current() user are
|
|
366
|
+
// gated on `targetUserId`. If it's missing (no userId param, no logged-in
|
|
367
|
+
// user) the optimistic update in onMutate would have early-returned too,
|
|
368
|
+
// so neither cache was ever touched — there's nothing to reconcile here.
|
|
369
|
+
onSuccess: (data, { userId, settings }) => {
|
|
231
370
|
const targetUserId = userId || user?.id;
|
|
232
|
-
if (targetUserId)
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
371
|
+
if (!targetUserId)
|
|
372
|
+
return;
|
|
373
|
+
const incoming = (data ?? {});
|
|
374
|
+
const requested = (settings ?? {});
|
|
375
|
+
queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), (previous) => ({
|
|
376
|
+
...(previous ?? {}),
|
|
377
|
+
...requested,
|
|
378
|
+
...incoming, // server wins for fields it explicitly returned
|
|
379
|
+
}));
|
|
236
380
|
const currentUser = queryClient.getQueryData(queryKeys.accounts.current());
|
|
237
381
|
if (currentUser) {
|
|
238
382
|
const updatedUser = {
|
|
239
383
|
...currentUser,
|
|
240
|
-
privacySettings:
|
|
384
|
+
privacySettings: {
|
|
385
|
+
...(currentUser.privacySettings ?? {}),
|
|
386
|
+
...requested,
|
|
387
|
+
...incoming,
|
|
388
|
+
},
|
|
241
389
|
};
|
|
242
390
|
queryClient.setQueryData(queryKeys.accounts.current(), updatedUser);
|
|
243
|
-
// Update authStore so frontend components see the changes immediately
|
|
244
391
|
useAuthStore.getState().setUser(updatedUser);
|
|
245
392
|
}
|
|
246
|
-
invalidateAccountQueries
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const targetUserId = userId || user?.id;
|
|
251
|
-
if (targetUserId) {
|
|
252
|
-
queryClient.invalidateQueries({ queryKey: queryKeys.privacy.settings(targetUserId) });
|
|
253
|
-
}
|
|
254
|
-
queryClient.invalidateQueries({ queryKey: queryKeys.accounts.current() });
|
|
393
|
+
// Deliberately NOT invalidating any queries here. invalidateAccountQueries
|
|
394
|
+
// invalidates accounts.all which is the prefix for accounts.current(),
|
|
395
|
+
// triggering a background refetch of useCurrentUser that would overwrite
|
|
396
|
+
// the merged state above. The onSuccess merge is the source of truth.
|
|
255
397
|
},
|
|
256
398
|
});
|
|
257
399
|
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Mutation Hooks
|
|
3
|
+
*
|
|
4
|
+
* Write side of the per-user JSON KV store. Both `useSetAppData` and
|
|
5
|
+
* `useDeleteAppData` apply optimistic updates against the two query keys
|
|
6
|
+
* that observe this data (`appDataQueryKeys.value` and the surrounding
|
|
7
|
+
* `appDataQueryKeys.namespace`) and roll back on error.
|
|
8
|
+
*
|
|
9
|
+
* When the underlying request fails because the endpoint isn't reachable
|
|
10
|
+
* (404 / network), the mutation still surfaces the error — write attempts
|
|
11
|
+
* are user-initiated and the caller may want to retry or fall back to
|
|
12
|
+
* local persistence. Reads are silent about missing endpoints; writes are
|
|
13
|
+
* not.
|
|
14
|
+
*/
|
|
15
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
16
|
+
import { authenticatedApiCall } from '@oxyhq/core';
|
|
17
|
+
import { useWebOxy } from '../../WebOxyProvider';
|
|
18
|
+
import { appDataQueryKeys } from '../queries/appDataQueryKeys';
|
|
19
|
+
/**
|
|
20
|
+
* Upsert a per-user JSON value. Returns the value the server confirmed it
|
|
21
|
+
* stored — typically identical to the input but consumers should prefer the
|
|
22
|
+
* returned value (the server is the source of truth).
|
|
23
|
+
*
|
|
24
|
+
* Applies optimistic updates against both the single-value query key and
|
|
25
|
+
* the surrounding namespace query key, then rolls back on error.
|
|
26
|
+
*/
|
|
27
|
+
export const useSetAppData = () => {
|
|
28
|
+
const { oxyServices, activeSessionId } = useWebOxy();
|
|
29
|
+
const queryClient = useQueryClient();
|
|
30
|
+
return useMutation({
|
|
31
|
+
mutationKey: ['appData', 'set'],
|
|
32
|
+
mutationFn: async ({ namespace, key, value }) => {
|
|
33
|
+
return authenticatedApiCall(oxyServices, activeSessionId, () => oxyServices.setAppData(namespace, key, value));
|
|
34
|
+
},
|
|
35
|
+
onMutate: async ({ namespace, key, value }) => {
|
|
36
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
37
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
38
|
+
await Promise.all([
|
|
39
|
+
queryClient.cancelQueries({ queryKey: valueKey }),
|
|
40
|
+
queryClient.cancelQueries({ queryKey: namespaceKey }),
|
|
41
|
+
]);
|
|
42
|
+
const previousValue = queryClient.getQueryData(valueKey);
|
|
43
|
+
const previousNamespace = queryClient.getQueryData(namespaceKey);
|
|
44
|
+
queryClient.setQueryData(valueKey, value);
|
|
45
|
+
if (previousNamespace) {
|
|
46
|
+
queryClient.setQueryData(namespaceKey, {
|
|
47
|
+
...previousNamespace,
|
|
48
|
+
[key]: value,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return { previousValue, previousNamespace };
|
|
52
|
+
},
|
|
53
|
+
onError: (_error, { namespace, key }, context) => {
|
|
54
|
+
if (!context)
|
|
55
|
+
return;
|
|
56
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
57
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
58
|
+
// Restore exactly the snapshots we captured in onMutate. Don't merge
|
|
59
|
+
// with whatever's currently in the cache — that could splice in writes
|
|
60
|
+
// from concurrent mutations and undo their state.
|
|
61
|
+
queryClient.setQueryData(valueKey, context.previousValue ?? null);
|
|
62
|
+
if (context.previousNamespace !== undefined) {
|
|
63
|
+
queryClient.setQueryData(namespaceKey, context.previousNamespace);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
onSuccess: (data, { namespace, key }) => {
|
|
67
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
68
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
69
|
+
queryClient.setQueryData(valueKey, data);
|
|
70
|
+
const existingNamespace = queryClient.getQueryData(namespaceKey);
|
|
71
|
+
if (existingNamespace) {
|
|
72
|
+
queryClient.setQueryData(namespaceKey, {
|
|
73
|
+
...existingNamespace,
|
|
74
|
+
[key]: data,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Delete a per-user JSON value. Optimistically removes the entry from the
|
|
82
|
+
* single-value cache and from the surrounding namespace map, then rolls back
|
|
83
|
+
* on error.
|
|
84
|
+
*/
|
|
85
|
+
export const useDeleteAppData = () => {
|
|
86
|
+
const { oxyServices, activeSessionId } = useWebOxy();
|
|
87
|
+
const queryClient = useQueryClient();
|
|
88
|
+
return useMutation({
|
|
89
|
+
mutationKey: ['appData', 'delete'],
|
|
90
|
+
mutationFn: async ({ namespace, key }) => {
|
|
91
|
+
await authenticatedApiCall(oxyServices, activeSessionId, () => oxyServices.deleteAppData(namespace, key));
|
|
92
|
+
},
|
|
93
|
+
onMutate: async ({ namespace, key }) => {
|
|
94
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
95
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
96
|
+
await Promise.all([
|
|
97
|
+
queryClient.cancelQueries({ queryKey: valueKey }),
|
|
98
|
+
queryClient.cancelQueries({ queryKey: namespaceKey }),
|
|
99
|
+
]);
|
|
100
|
+
const previousValue = queryClient.getQueryData(valueKey);
|
|
101
|
+
const previousNamespace = queryClient.getQueryData(namespaceKey);
|
|
102
|
+
queryClient.setQueryData(valueKey, null);
|
|
103
|
+
if (previousNamespace && key in previousNamespace) {
|
|
104
|
+
const next = { ...previousNamespace };
|
|
105
|
+
delete next[key];
|
|
106
|
+
queryClient.setQueryData(namespaceKey, next);
|
|
107
|
+
}
|
|
108
|
+
return { previousValue, previousNamespace };
|
|
109
|
+
},
|
|
110
|
+
onError: (_error, { namespace, key }, context) => {
|
|
111
|
+
if (!context)
|
|
112
|
+
return;
|
|
113
|
+
const valueKey = appDataQueryKeys.value(namespace, key);
|
|
114
|
+
const namespaceKey = appDataQueryKeys.namespace(namespace);
|
|
115
|
+
queryClient.setQueryData(valueKey, context.previousValue ?? null);
|
|
116
|
+
if (context.previousNamespace !== undefined) {
|
|
117
|
+
queryClient.setQueryData(namespaceKey, context.previousNamespace);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
onSuccess: (_data, { namespace, key }) => {
|
|
121
|
+
queryClient.setQueryData(appDataQueryKeys.value(namespace, key), null);
|
|
122
|
+
// Confirm the value is gone from the namespace cache too. If the
|
|
123
|
+
// optimistic update wasn't applied (e.g. cache was empty), this is a
|
|
124
|
+
// no-op; if it was, we already removed it in onMutate, so this is also
|
|
125
|
+
// a no-op. The work happens in onMutate — onSuccess is the commit point.
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys + error utilities for `useAppData` hooks.
|
|
3
|
+
*
|
|
4
|
+
* Lives next to the hook file so consumers can import the keys directly
|
|
5
|
+
* when they need to imperatively invalidate a value (e.g. after a non-React
|
|
6
|
+
* write through `oxyServices.setAppData`).
|
|
7
|
+
*/
|
|
8
|
+
export const appDataQueryKeys = {
|
|
9
|
+
all: ['appData'],
|
|
10
|
+
namespaces: () => [...appDataQueryKeys.all, 'namespace'],
|
|
11
|
+
namespace: (namespace) => [...appDataQueryKeys.namespaces(), namespace],
|
|
12
|
+
values: () => [...appDataQueryKeys.all, 'value'],
|
|
13
|
+
value: (namespace, key) => [...appDataQueryKeys.values(), namespace, key],
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* True when `error` indicates the app-data endpoint isn't reachable — either
|
|
17
|
+
* because the API deployment doesn't have it yet (404) or there's a network
|
|
18
|
+
* failure with no response. We treat these as "no value stored" so consumers
|
|
19
|
+
* fall back to local persistence without surfacing a user-facing error.
|
|
20
|
+
*
|
|
21
|
+
* Anything else (401, 403, 500) propagates normally — those are real bugs
|
|
22
|
+
* the auth or retry pipeline needs to see.
|
|
23
|
+
*/
|
|
24
|
+
export function isMissingAppDataEndpointError(error) {
|
|
25
|
+
if (!error || typeof error !== 'object') {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const candidate = error;
|
|
29
|
+
const status = candidate.status ?? candidate.statusCode ?? candidate.response?.status;
|
|
30
|
+
// 404: endpoint not deployed on this API instance yet.
|
|
31
|
+
if (status === 404)
|
|
32
|
+
return true;
|
|
33
|
+
// Network errors: no response received at all. Common during local dev
|
|
34
|
+
// when the API server is down, or when offline.
|
|
35
|
+
if (candidate.code === 'NETWORK_ERROR')
|
|
36
|
+
return true;
|
|
37
|
+
const message = typeof candidate.message === 'string' ? candidate.message : '';
|
|
38
|
+
if (message.includes('Network Error') || message.includes('Failed to fetch')) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
@@ -12,3 +12,6 @@ export { useSessions, useSession, useDeviceSessions, useUserDevices, useSecurity
|
|
|
12
12
|
export { useSecurityActivity, useRecentSecurityActivity, } from './useSecurityQueries';
|
|
13
13
|
// Query keys and invalidation helpers (for advanced usage)
|
|
14
14
|
export { queryKeys, invalidateAccountQueries, invalidateUserQueries, invalidateSessionQueries } from './queryKeys';
|
|
15
|
+
// App-data KV store query hooks
|
|
16
|
+
export { useAppData, useAppDataNamespace } from './useAppData';
|
|
17
|
+
export { appDataQueryKeys, isMissingAppDataEndpointError } from './appDataQueryKeys';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Query Hooks
|
|
3
|
+
*
|
|
4
|
+
* Read side of the `/users/me/app-data/...` per-user JSON KV store. Gated on
|
|
5
|
+
* `isAuthenticated` — when signed out the query stays `enabled: false` and
|
|
6
|
+
* `data` is `null`, so consumers can fall back to localStorage without ever
|
|
7
|
+
* issuing a doomed request.
|
|
8
|
+
*
|
|
9
|
+
* Errors from the network (404 because the endpoint isn't deployed yet,
|
|
10
|
+
* 401 because the session lapsed, etc.) are not user-facing here. Hooks
|
|
11
|
+
* return `data: null` on error so the calling component renders the
|
|
12
|
+
* "nothing yet" state and the consuming app can quietly fall back to local
|
|
13
|
+
* persistence. Mutations still propagate errors so write attempts surface
|
|
14
|
+
* a toast — only reads are silent.
|
|
15
|
+
*/
|
|
16
|
+
import { useQuery } from '@tanstack/react-query';
|
|
17
|
+
import { authenticatedApiCall } from '@oxyhq/core';
|
|
18
|
+
import { useWebOxy } from '../../WebOxyProvider';
|
|
19
|
+
import { appDataQueryKeys, isMissingAppDataEndpointError } from './appDataQueryKeys';
|
|
20
|
+
/**
|
|
21
|
+
* Read a single per-user JSON value.
|
|
22
|
+
*
|
|
23
|
+
* @param namespace - kebab/snake-case identifier (e.g. `"academy"`).
|
|
24
|
+
* @param key - kebab/snake-case identifier (e.g. course slug).
|
|
25
|
+
* @param options - optional `enabled`/`staleTime`/`gcTime` overrides.
|
|
26
|
+
*
|
|
27
|
+
* @returns A `useQuery` result with `data` of type `T | null`. The query
|
|
28
|
+
* stays disabled when the user is signed out; when enabled but the server
|
|
29
|
+
* has no stored value, `data` is `null`. Reads that fail because the
|
|
30
|
+
* endpoint isn't reachable also resolve to `null` so the consumer can
|
|
31
|
+
* fall back to local persistence.
|
|
32
|
+
*/
|
|
33
|
+
export const useAppData = (namespace, key, options) => {
|
|
34
|
+
const { oxyServices, activeSessionId, isAuthenticated } = useWebOxy();
|
|
35
|
+
return useQuery({
|
|
36
|
+
queryKey: appDataQueryKeys.value(namespace, key),
|
|
37
|
+
queryFn: async () => {
|
|
38
|
+
try {
|
|
39
|
+
return await authenticatedApiCall(oxyServices, activeSessionId, () => oxyServices.getAppData(namespace, key));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// Endpoint not deployed yet, no network, etc. — return null so the
|
|
43
|
+
// consumer falls back to localStorage rather than rendering a broken
|
|
44
|
+
// UI state. Authentication errors still bubble up so the auth retry
|
|
45
|
+
// pipeline can surface them at the provider level.
|
|
46
|
+
if (isMissingAppDataEndpointError(error)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
enabled: (options?.enabled !== false) && isAuthenticated,
|
|
53
|
+
staleTime: options?.staleTime ?? 60 * 1000,
|
|
54
|
+
gcTime: options?.gcTime ?? 30 * 60 * 1000,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Read every value in a namespace.
|
|
59
|
+
*
|
|
60
|
+
* @returns A `useQuery` result with `data` as a `Record<string, T>`. Empty
|
|
61
|
+
* object when the namespace contains nothing (or when fetching failed).
|
|
62
|
+
*/
|
|
63
|
+
export const useAppDataNamespace = (namespace, options) => {
|
|
64
|
+
const { oxyServices, activeSessionId, isAuthenticated } = useWebOxy();
|
|
65
|
+
return useQuery({
|
|
66
|
+
queryKey: appDataQueryKeys.namespace(namespace),
|
|
67
|
+
queryFn: async () => {
|
|
68
|
+
try {
|
|
69
|
+
return await authenticatedApiCall(oxyServices, activeSessionId, () => oxyServices.listAppData(namespace));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (isMissingAppDataEndpointError(error)) {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
enabled: (options?.enabled !== false) && isAuthenticated,
|
|
79
|
+
staleTime: options?.staleTime ?? 60 * 1000,
|
|
80
|
+
gcTime: options?.gcTime ?? 30 * 60 * 1000,
|
|
81
|
+
});
|
|
82
|
+
};
|