@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.
Files changed (38) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/WebOxyProvider.js +37 -0
  3. package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
  4. package/dist/cjs/hooks/queryClient.js +136 -92
  5. package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
  6. package/dist/cjs/hooks/useSessionSocket.js +81 -94
  7. package/dist/cjs/index.js +1 -2
  8. package/dist/cjs/utils/sessionHelpers.js +3 -1
  9. package/dist/cjs/utils/storageHelpers.js +36 -10
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/WebOxyProvider.js +38 -1
  12. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  13. package/dist/esm/hooks/queryClient.js +132 -89
  14. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  15. package/dist/esm/hooks/useSessionSocket.js +81 -94
  16. package/dist/esm/index.js +1 -1
  17. package/dist/esm/utils/sessionHelpers.js +3 -1
  18. package/dist/esm/utils/storageHelpers.js +36 -10
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/WebOxyProvider.d.ts +1 -1
  21. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  22. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  23. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  24. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  25. package/dist/types/hooks/queryClient.d.ts +24 -10
  26. package/dist/types/hooks/useAssets.d.ts +1 -1
  27. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  30. package/package.json +22 -3
  31. package/src/WebOxyProvider.tsx +39 -1
  32. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  33. package/src/hooks/queryClient.ts +140 -83
  34. package/src/hooks/useFileDownloadUrl.ts +15 -39
  35. package/src/hooks/useSessionSocket.ts +123 -91
  36. package/src/index.ts +1 -1
  37. package/src/utils/sessionHelpers.ts +3 -1
  38. package/src/utils/storageHelpers.ts +49 -10
@@ -1,7 +1,7 @@
1
1
  import { useMutation, useQueryClient } from '@tanstack/react-query';
2
2
  import { authenticatedApiCall } from '@oxyhq/core';
3
- import type { User } from '@oxyhq/core';
4
- import { queryKeys, invalidateAccountQueries, invalidateUserQueries } from '../queries/queryKeys';
3
+ import type { PrivacySettings, User } from '@oxyhq/core';
4
+ import { queryKeys, invalidateAccountQueries, invalidateUserQueries, invalidateSessionQueries } from '../queries/queryKeys';
5
5
  import { useWebOxy } from '../../WebOxyProvider';
6
6
  import { toast } from 'sonner';
7
7
  import { refreshAvatarInStore } from '../../utils/avatarUtils';
@@ -11,7 +11,7 @@ import { useAuthStore } from '../../stores/authStore';
11
11
  * Update user profile with optimistic updates and offline queue support
12
12
  */
13
13
  export const useUpdateProfile = () => {
14
- const { oxyServices, activeSessionId, user } = useWebOxy();
14
+ const { oxyServices, activeSessionId } = useWebOxy();
15
15
  const queryClient = useQueryClient();
16
16
 
17
17
  return useMutation({
@@ -48,12 +48,31 @@ export const useUpdateProfile = () => {
48
48
 
49
49
  return { previousUser };
50
50
  },
51
- // On error, rollback
51
+ // On error, rollback ONLY the keys this mutation tried to change
52
52
  onError: (error, updates, context) => {
53
- if (context?.previousUser) {
54
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
53
+ if (context?.previousUser && updates) {
54
+ const previousUser = context.previousUser;
55
+ const changedKeys = Object.keys(updates) as Array<keyof User>;
56
+ const partialRollback = changedKeys.reduce<Partial<User>>((acc, key) => {
57
+ (acc as Record<string, unknown>)[key as string] = previousUser[key];
58
+ return acc;
59
+ }, {});
60
+
61
+ const current = queryClient.getQueryData<User>(queryKeys.accounts.current());
62
+ if (current) {
63
+ queryClient.setQueryData<User>(queryKeys.accounts.current(), {
64
+ ...current,
65
+ ...partialRollback,
66
+ });
67
+ }
55
68
  if (activeSessionId) {
56
- queryClient.setQueryData(queryKeys.users.profile(activeSessionId), context.previousUser);
69
+ const currentProfile = queryClient.getQueryData<User>(queryKeys.users.profile(activeSessionId));
70
+ if (currentProfile) {
71
+ queryClient.setQueryData<User>(queryKeys.users.profile(activeSessionId), {
72
+ ...currentProfile,
73
+ ...partialRollback,
74
+ });
75
+ }
57
76
  }
58
77
  }
59
78
  toast.error(error instanceof Error ? error.message : 'Failed to update profile');
@@ -65,18 +84,22 @@ export const useUpdateProfile = () => {
65
84
  if (activeSessionId) {
66
85
  queryClient.setQueryData(queryKeys.users.profile(activeSessionId), data);
67
86
  }
68
-
87
+
69
88
  // Update authStore so frontend components see the changes immediately
70
89
  useAuthStore.getState().setUser(data);
71
-
90
+
72
91
  // If avatar was updated, refresh accountStore with cache-busted URL
73
92
  if (updates.avatar && activeSessionId && oxyServices) {
74
93
  refreshAvatarInStore(activeSessionId, updates.avatar, oxyServices);
75
94
  }
76
-
77
- // Invalidate all related queries to refresh everywhere
95
+
96
+ // Invalidate all related queries so every consumer (AccountSwitcher,
97
+ // session lists, managed accounts, etc.) refetches the fresh profile.
98
+ // Critical right after `username` is set the first time, when every
99
+ // cached "session profile" still reports the user as unnamed.
78
100
  invalidateUserQueries(queryClient);
79
101
  invalidateAccountQueries(queryClient);
102
+ invalidateSessionQueries(queryClient);
80
103
  },
81
104
  });
82
105
  };
@@ -122,11 +145,25 @@ export const useUploadAvatar = () => {
122
145
 
123
146
  return { previousUser };
124
147
  },
125
- onError: (error, file, context) => {
148
+ onError: (error, _file, context) => {
149
+ // Avatar upload only mutates the `avatar` field — restore only that key
126
150
  if (context?.previousUser) {
127
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
151
+ const previousAvatar = context.previousUser.avatar;
152
+ const current = queryClient.getQueryData<User>(queryKeys.accounts.current());
153
+ if (current) {
154
+ queryClient.setQueryData<User>(queryKeys.accounts.current(), {
155
+ ...current,
156
+ avatar: previousAvatar,
157
+ });
158
+ }
128
159
  if (activeSessionId) {
129
- queryClient.setQueryData(queryKeys.users.profile(activeSessionId), context.previousUser);
160
+ const currentProfile = queryClient.getQueryData<User>(queryKeys.users.profile(activeSessionId));
161
+ if (currentProfile) {
162
+ queryClient.setQueryData<User>(queryKeys.users.profile(activeSessionId), {
163
+ ...currentProfile,
164
+ avatar: previousAvatar,
165
+ });
166
+ }
130
167
  }
131
168
  }
132
169
  toast.error(error instanceof Error ? error.message : 'Failed to upload avatar');
@@ -144,27 +181,69 @@ export const useUploadAvatar = () => {
144
181
  if (data?.avatar && activeSessionId && oxyServices) {
145
182
  refreshAvatarInStore(activeSessionId, data.avatar, oxyServices);
146
183
  }
147
-
148
- // Invalidate all related queries to refresh everywhere
184
+
185
+ // Invalidate all related queries to refresh everywhere, including the
186
+ // sessions cache so other-account avatars update too.
149
187
  invalidateUserQueries(queryClient);
150
188
  invalidateAccountQueries(queryClient);
189
+ invalidateSessionQueries(queryClient);
151
190
  toast.success('Avatar updated successfully');
152
191
  },
153
192
  });
154
193
  };
155
194
 
156
195
  /**
157
- * Update account settings
196
+ * Variables accepted by the `useUpdateAccountSettings` mutation.
197
+ *
198
+ * `currentUser` is captured at dispatch time so the rebuilt user object the
199
+ * mutation returns is computed against a stable snapshot — NOT the cache
200
+ * value at the moment the API call settles. Reading from the cache inside
201
+ * `mutationFn` would race with sibling optimistic updates: a concurrent
202
+ * write could already have overwritten the cache by the time the privacy
203
+ * update returns, causing the rebuilt user to clobber the sibling's
204
+ * optimistic value.
205
+ */
206
+ interface UpdateAccountSettingsVariables {
207
+ updates: Partial<PrivacySettings>;
208
+ currentUser: User;
209
+ }
210
+
211
+ /**
212
+ * Update account settings (privacy preferences).
213
+ *
214
+ * Privacy settings are not part of the `PUT /users/me` allow-list; the API
215
+ * would silently drop them. Route through `updatePrivacySettings` so the
216
+ * dedicated `PATCH /privacy/:id/privacy` endpoint performs a dot-path merge
217
+ * and returns the updated `privacySettings` object.
218
+ *
219
+ * The returned object exposes the standard mutation surface PLUS a
220
+ * convenience `mutate(updates)` / `mutateAsync(updates)` that snapshots
221
+ * the current user from `useWebOxy()` at dispatch time.
158
222
  */
159
223
  export const useUpdateAccountSettings = () => {
160
- const { oxyServices, activeSessionId } = useWebOxy();
224
+ const { oxyServices, activeSessionId, user } = useWebOxy();
161
225
  const queryClient = useQueryClient();
162
226
 
163
- return useMutation({
164
- mutationFn: async (settings: Record<string, any>) => {
165
- return await oxyServices.updateProfile({ privacySettings: settings });
227
+ const mutation = useMutation({
228
+ mutationFn: async ({ updates, currentUser }: UpdateAccountSettingsVariables) => {
229
+ const userId = currentUser.id;
230
+ if (!userId) {
231
+ throw new Error('User ID is required to update account settings');
232
+ }
233
+ const updatedPrivacy = await authenticatedApiCall<PrivacySettings>(
234
+ oxyServices,
235
+ activeSessionId,
236
+ () => oxyServices.updatePrivacySettings(updates, userId)
237
+ );
238
+ // Rebuild against the dispatch-time snapshot, NOT the live cache.
239
+ // The cache may have been mutated by a sibling write between
240
+ // dispatch and settle.
241
+ return {
242
+ ...currentUser,
243
+ privacySettings: updatedPrivacy,
244
+ };
166
245
  },
167
- onMutate: async (settings) => {
246
+ onMutate: async ({ updates }) => {
168
247
  await queryClient.cancelQueries({ queryKey: queryKeys.accounts.settings() });
169
248
  const previousUser = queryClient.getQueryData<User>(queryKeys.accounts.current());
170
249
 
@@ -173,25 +252,42 @@ export const useUpdateAccountSettings = () => {
173
252
  ...previousUser,
174
253
  privacySettings: {
175
254
  ...previousUser.privacySettings,
176
- ...settings,
255
+ ...updates,
177
256
  },
178
257
  });
179
258
  }
180
259
 
181
260
  return { previousUser };
182
261
  },
183
- onError: (error, settings, context) => {
184
- if (context?.previousUser) {
185
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
262
+ onError: (error, { updates }, context) => {
263
+ // Restore only the privacySettings keys this mutation tried to change
264
+ if (context?.previousUser && updates) {
265
+ const previousPrivacy = context.previousUser.privacySettings ?? {};
266
+ const changedKeys = Object.keys(updates) as Array<keyof PrivacySettings>;
267
+ const partialPrivacyRollback = changedKeys.reduce<Partial<PrivacySettings>>((acc, key) => {
268
+ (acc as Record<string, unknown>)[key as string] = (previousPrivacy as Record<string, unknown>)[key as string];
269
+ return acc;
270
+ }, {});
271
+
272
+ const current = queryClient.getQueryData<User>(queryKeys.accounts.current());
273
+ if (current) {
274
+ queryClient.setQueryData<User>(queryKeys.accounts.current(), {
275
+ ...current,
276
+ privacySettings: {
277
+ ...(current.privacySettings ?? {}),
278
+ ...partialPrivacyRollback,
279
+ },
280
+ });
281
+ }
186
282
  }
187
283
  toast.error(error instanceof Error ? error.message : 'Failed to update settings');
188
284
  },
189
285
  onSuccess: (data) => {
190
286
  queryClient.setQueryData(queryKeys.accounts.current(), data);
191
-
287
+
192
288
  // Update authStore so frontend components see the changes immediately
193
289
  useAuthStore.getState().setUser(data);
194
-
290
+
195
291
  invalidateAccountQueries(queryClient);
196
292
  toast.success('Settings updated successfully');
197
293
  },
@@ -199,6 +295,27 @@ export const useUpdateAccountSettings = () => {
199
295
  queryClient.invalidateQueries({ queryKey: queryKeys.accounts.settings() });
200
296
  },
201
297
  });
298
+
299
+ // Wrap mutate/mutateAsync so call sites pass a plain settings object and
300
+ // the current user is captured at dispatch time.
301
+ return {
302
+ ...mutation,
303
+ mutate: (updates: Partial<PrivacySettings>): void => {
304
+ const currentUser = user ?? queryClient.getQueryData<User>(queryKeys.accounts.current());
305
+ if (!currentUser) {
306
+ toast.error('Cannot update account settings: no current user');
307
+ return;
308
+ }
309
+ mutation.mutate({ updates, currentUser });
310
+ },
311
+ mutateAsync: async (updates: Partial<PrivacySettings>): Promise<User> => {
312
+ const currentUser = user ?? queryClient.getQueryData<User>(queryKeys.accounts.current());
313
+ if (!currentUser) {
314
+ throw new Error('Cannot update account settings: no current user');
315
+ }
316
+ return mutation.mutateAsync({ updates, currentUser });
317
+ },
318
+ };
202
319
  };
203
320
 
204
321
  /**
@@ -209,13 +326,13 @@ export const useUpdatePrivacySettings = () => {
209
326
  const queryClient = useQueryClient();
210
327
 
211
328
  return useMutation({
212
- mutationFn: async ({ settings, userId }: { settings: Record<string, any>; userId?: string }) => {
329
+ mutationFn: async ({ settings, userId }: { settings: Partial<PrivacySettings>; userId?: string }) => {
213
330
  const targetUserId = userId || user?.id;
214
331
  if (!targetUserId) {
215
332
  throw new Error('User ID is required');
216
333
  }
217
334
 
218
- return authenticatedApiCall<Record<string, unknown>>(
335
+ return authenticatedApiCall<PrivacySettings>(
219
336
  oxyServices,
220
337
  activeSessionId,
221
338
  () => oxyServices.updatePrivacySettings(settings, targetUserId)
@@ -231,12 +348,12 @@ export const useUpdatePrivacySettings = () => {
231
348
  await queryClient.cancelQueries({ queryKey: queryKeys.accounts.current() });
232
349
 
233
350
  // Snapshot previous values
234
- const previousPrivacySettings = queryClient.getQueryData(queryKeys.privacy.settings(targetUserId));
351
+ const previousPrivacySettings = queryClient.getQueryData<PrivacySettings>(queryKeys.privacy.settings(targetUserId));
235
352
  const previousUser = queryClient.getQueryData<User>(queryKeys.accounts.current());
236
353
 
237
354
  // Optimistically update privacy settings
238
355
  if (previousPrivacySettings) {
239
- queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), {
356
+ queryClient.setQueryData<PrivacySettings>(queryKeys.privacy.settings(targetUserId), {
240
357
  ...previousPrivacySettings,
241
358
  ...settings,
242
359
  });
@@ -255,44 +372,100 @@ export const useUpdatePrivacySettings = () => {
255
372
 
256
373
  return { previousPrivacySettings, previousUser };
257
374
  },
258
- // On error, rollback
259
- onError: (error, { userId }, context) => {
375
+ // On error, rollback ONLY the privacy keys this mutation tried to change.
376
+ // Restoring the entire previous object would wipe out other concurrent
377
+ // optimistic updates (e.g. user toggles two privacy switches in quick
378
+ // succession; failure on one must not revert the other).
379
+ onError: (error, { settings, userId }, context) => {
260
380
  const targetUserId = userId || user?.id;
261
- if (context?.previousPrivacySettings && targetUserId) {
262
- queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), context.previousPrivacySettings);
381
+ const changedKeys = settings ? (Object.keys(settings) as Array<keyof PrivacySettings>) : [];
382
+
383
+ // Rollback the privacy.settings query (partial)
384
+ if (context?.previousPrivacySettings && targetUserId && changedKeys.length > 0) {
385
+ const previousPrivacy = context.previousPrivacySettings as Record<string, unknown>;
386
+ const partialPrivacyRollback = changedKeys.reduce<Partial<PrivacySettings>>((acc, key) => {
387
+ (acc as Record<string, unknown>)[key as string] = previousPrivacy[key as string];
388
+ return acc;
389
+ }, {});
390
+ const currentPrivacy = queryClient.getQueryData<PrivacySettings>(queryKeys.privacy.settings(targetUserId));
391
+ if (currentPrivacy) {
392
+ queryClient.setQueryData<PrivacySettings>(queryKeys.privacy.settings(targetUserId), {
393
+ ...currentPrivacy,
394
+ ...partialPrivacyRollback,
395
+ });
396
+ }
263
397
  }
264
- if (context?.previousUser) {
265
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
398
+
399
+ // Rollback the accounts.current() user.privacySettings (partial)
400
+ if (context?.previousUser && changedKeys.length > 0) {
401
+ const previousPrivacy = (context.previousUser.privacySettings ?? {}) as Record<string, unknown>;
402
+ const partialPrivacyRollback = changedKeys.reduce<Partial<PrivacySettings>>((acc, key) => {
403
+ (acc as Record<string, unknown>)[key as string] = previousPrivacy[key as string];
404
+ return acc;
405
+ }, {});
406
+ const current = queryClient.getQueryData<User>(queryKeys.accounts.current());
407
+ if (current) {
408
+ queryClient.setQueryData<User>(queryKeys.accounts.current(), {
409
+ ...current,
410
+ privacySettings: {
411
+ ...(current.privacySettings ?? {}),
412
+ ...partialPrivacyRollback,
413
+ },
414
+ });
415
+ }
266
416
  }
417
+
418
+ // After partial rollback, reconcile against the server so the cache
419
+ // converges to the authoritative state for the failed keys.
420
+ if (targetUserId) {
421
+ queryClient.invalidateQueries({ queryKey: queryKeys.privacy.settings(targetUserId) });
422
+ }
423
+
267
424
  toast.error(error instanceof Error ? error.message : 'Failed to update privacy settings');
268
425
  },
269
- // On success, invalidate and refetch
270
- onSuccess: (data, { userId }) => {
426
+ // On success, MERGE the server response into the cached state. Older
427
+ // API builds returned only the changed field (or wiped the privacySettings
428
+ // subdocument when handed a partial update), which would clobber every
429
+ // other toggle if we blindly replaced. Defensive merge means the UI stays
430
+ // consistent regardless of server behaviour.
431
+ //
432
+ // BOTH the privacy.settings query AND the accounts.current() user are
433
+ // gated on `targetUserId`. If it's missing (no userId param, no logged-in
434
+ // user) the optimistic update in onMutate would have early-returned too,
435
+ // so neither cache was ever touched — there's nothing to reconcile here.
436
+ onSuccess: (data, { userId, settings }) => {
271
437
  const targetUserId = userId || user?.id;
272
- if (targetUserId) {
273
- queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), data);
274
- }
275
- // Also update account query if it contains privacy settings
438
+ if (!targetUserId) return;
439
+
440
+ const incoming = (data ?? {}) as PrivacySettings;
441
+ const requested = (settings ?? {}) as Partial<PrivacySettings>;
442
+
443
+ queryClient.setQueryData<PrivacySettings>(
444
+ queryKeys.privacy.settings(targetUserId),
445
+ (previous) => ({
446
+ ...(previous ?? {}),
447
+ ...requested,
448
+ ...incoming, // server wins for fields it explicitly returned
449
+ }),
450
+ );
451
+
276
452
  const currentUser = queryClient.getQueryData<User>(queryKeys.accounts.current());
277
453
  if (currentUser) {
278
454
  const updatedUser: User = {
279
455
  ...currentUser,
280
- privacySettings: data as { [key: string]: unknown },
456
+ privacySettings: {
457
+ ...(currentUser.privacySettings ?? {}),
458
+ ...requested,
459
+ ...incoming,
460
+ },
281
461
  };
282
462
  queryClient.setQueryData<User>(queryKeys.accounts.current(), updatedUser);
283
-
284
- // Update authStore so frontend components see the changes immediately
285
463
  useAuthStore.getState().setUser(updatedUser);
286
464
  }
287
- invalidateAccountQueries(queryClient);
288
- },
289
- // Always refetch after error or success
290
- onSettled: (data, error, { userId }) => {
291
- const targetUserId = userId || user?.id;
292
- if (targetUserId) {
293
- queryClient.invalidateQueries({ queryKey: queryKeys.privacy.settings(targetUserId) });
294
- }
295
- queryClient.invalidateQueries({ queryKey: queryKeys.accounts.current() });
465
+ // Deliberately NOT invalidating any queries here. invalidateAccountQueries
466
+ // invalidates accounts.all which is the prefix for accounts.current(),
467
+ // triggering a background refetch of useCurrentUser that would overwrite
468
+ // the merged state above. The onSuccess merge is the source of truth.
296
469
  },
297
470
  });
298
471
  };
@@ -331,7 +504,7 @@ export const useUploadFile = () => {
331
504
  }: {
332
505
  file: File;
333
506
  visibility?: 'private' | 'public' | 'unlisted';
334
- metadata?: Record<string, any>;
507
+ metadata?: Record<string, unknown>;
335
508
  onProgress?: (progress: number) => void;
336
509
  }) => {
337
510
  return authenticatedApiCall<UploadResult>(