@oxyhq/services 5.16.26 → 5.16.28

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.
@@ -34,6 +34,8 @@ import { translate } from '../../i18n';
34
34
  import { updateAvatarVisibility, updateProfileWithAvatar } from '../utils/avatarUtils';
35
35
  import { useAccountStore } from '../stores/accountStore';
36
36
  import { logger as loggerUtil } from '../../utils/loggerUtils';
37
+ import { useTransferStore, useTransferCodesForPersistence } from '../stores/transferStore';
38
+ import { useCheckPendingTransfers } from '../hooks/useTransferQueries';
37
39
 
38
40
  export interface OxyContextState {
39
41
  user: User | null;
@@ -387,20 +389,22 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
387
389
  oxyServices.clearCache();
388
390
  }, [queryClient, storage, clearSessionState, logger, oxyServices]);
389
391
 
392
+ // Extract Zustand store functions early (before they're used in callbacks)
393
+ const getAllPendingTransfersStore = useTransferStore((state) => state.getAllPendingTransfers);
394
+ const getActiveTransferIdStore = useTransferStore((state) => state.getActiveTransferId);
395
+ const storeTransferCodeStore = useTransferStore((state) => state.storeTransferCode);
396
+ const getTransferCodeStore = useTransferStore((state) => state.getTransferCode);
397
+ const updateTransferStateStore = useTransferStore((state) => state.updateTransferState);
398
+ const clearTransferCodeStore = useTransferStore((state) => state.clearTransferCode);
399
+
390
400
  // Transfer code management functions (must be defined before deleteIdentityAndClearAccount)
391
401
  const getAllPendingTransfers = useCallback(() => {
392
- const pending: Array<{ transferId: string; data: TransferCodeData }> = [];
393
- transferCodesRef.current.forEach((data, transferId) => {
394
- if (data.state === 'pending') {
395
- pending.push({ transferId, data });
396
- }
397
- });
398
- return pending;
399
- }, []);
402
+ return getAllPendingTransfersStore();
403
+ }, [getAllPendingTransfersStore]);
400
404
 
401
405
  const getActiveTransferId = useCallback(() => {
402
- return activeTransferIdRef.current;
403
- }, []);
406
+ return getActiveTransferIdStore();
407
+ }, [getActiveTransferIdStore]);
404
408
 
405
409
  // Delete identity and clear all account data
406
410
  // In accounts app, deleting identity means losing the account completely
@@ -415,7 +419,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
415
419
  const pendingTransfers = getAllPendingTransfers();
416
420
  if (pendingTransfers.length > 0) {
417
421
  const activeTransferId = getActiveTransferId();
418
- const hasActiveTransfer = activeTransferId && pendingTransfers.some(t => t.transferId === activeTransferId);
422
+ const hasActiveTransfer = activeTransferId && pendingTransfers.some((t: { transferId: string; data: any }) => t.transferId === activeTransferId);
419
423
 
420
424
  if (hasActiveTransfer) {
421
425
  throw new Error(
@@ -510,6 +514,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
510
514
  loggerUtil.debug('Identity sync timeout (expected when offline)', { component: 'OxyContext', method: 'checkNetworkAndSync' }, syncError as unknown);
511
515
  }
512
516
  }
517
+
518
+ // Check for pending transfers that may have completed while offline
519
+ // This is handled by useCheckPendingTransfers hook which runs automatically
520
+ // when authenticated and online
513
521
  }
514
522
 
515
523
  // TanStack Query will automatically retry pending mutations
@@ -551,7 +559,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
551
559
  clearTimeout(checkTimeout);
552
560
  }
553
561
  };
554
- }, [oxyServices, storage, syncIdentity, logger]);
562
+ }, [oxyServices, storage, syncIdentity, logger, hasIdentity, isAuthenticated]);
555
563
 
556
564
  const { getDeviceSessions, logoutAllDeviceSessions, updateDeviceName } = useDeviceManagement({
557
565
  oxyServices,
@@ -670,207 +678,110 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
670
678
  // The JWT token's userId field contains the MongoDB ObjectId
671
679
  const userId = oxyServices.getCurrentUserId() || user?.id;
672
680
 
673
- // Transfer code storage interface
674
- interface TransferCodeData {
675
- code: string;
676
- sourceDeviceId: string | null;
677
- publicKey: string;
678
- timestamp: number;
679
- state: 'pending' | 'completed' | 'failed';
680
- }
681
-
682
- // Store transfer codes in memory for verification (also persisted to storage)
683
- // Map: transferId -> TransferCodeData
684
- const transferCodesRef = useRef<Map<string, TransferCodeData>>(new Map());
685
- const activeTransferIdRef = useRef<string | null>(null);
681
+ // Use Zustand store for transfer state management
686
682
  const TRANSFER_CODES_STORAGE_KEY = `${storageKeyPrefix}_transfer_codes`;
687
683
  const ACTIVE_TRANSFER_STORAGE_KEY = `${storageKeyPrefix}_active_transfer_id`;
684
+ const isRestored = useTransferStore((state) => state.isRestored);
685
+ const restoreFromStorage = useTransferStore((state) => state.restoreFromStorage);
686
+ const markRestored = useTransferStore((state) => state.markRestored);
687
+ const cleanupExpired = useTransferStore((state) => state.cleanupExpired);
688
688
 
689
- // Load transfer codes from storage on startup
689
+ // Load transfer codes from storage on startup (only once)
690
690
  useEffect(() => {
691
- if (!storage || !isStorageReady) return;
691
+ if (!storage || !isStorageReady || isRestored) return;
692
692
 
693
693
  const loadTransferCodes = async () => {
694
694
  try {
695
695
  // Load transfer codes
696
696
  const storedCodes = await storage.getItem(TRANSFER_CODES_STORAGE_KEY);
697
- if (storedCodes) {
698
- const parsed = JSON.parse(storedCodes);
699
- const now = Date.now();
700
- const fifteenMinutes = 15 * 60 * 1000;
701
-
702
- // Only restore non-expired pending transfers
703
- Object.entries(parsed).forEach(([transferId, data]: [string, any]) => {
704
- if (data.state === 'pending' && (now - data.timestamp) < fifteenMinutes) {
705
- transferCodesRef.current.set(transferId, data);
706
- if (__DEV__) {
707
- logger('Restored pending transfer from storage', { transferId });
708
- }
709
- }
697
+ const storedActiveTransferId = await storage.getItem(ACTIVE_TRANSFER_STORAGE_KEY);
698
+
699
+ const parsedCodes = storedCodes ? JSON.parse(storedCodes) : {};
700
+ const activeTransferId = storedActiveTransferId || null;
701
+
702
+ // Restore to Zustand store (store handles validation and expiration)
703
+ restoreFromStorage(parsedCodes, activeTransferId);
704
+ markRestored();
705
+
706
+ if (__DEV__ && Object.keys(parsedCodes).length > 0) {
707
+ logger('Restored transfer codes from storage', {
708
+ count: Object.keys(parsedCodes).length,
709
+ hasActiveTransfer: !!activeTransferId,
710
710
  });
711
711
  }
712
-
713
- // Load active transfer ID
714
- const activeTransferId = await storage.getItem(ACTIVE_TRANSFER_STORAGE_KEY);
715
- if (activeTransferId) {
716
- // Verify it's still valid
717
- const transferData = transferCodesRef.current.get(activeTransferId);
718
- if (transferData && transferData.state === 'pending') {
719
- activeTransferIdRef.current = activeTransferId;
720
- if (__DEV__) {
721
- logger('Restored active transfer ID from storage', { transferId: activeTransferId });
722
- }
723
- } else {
724
- // Clear invalid active transfer
725
- await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
726
- }
727
- }
728
712
  } catch (error) {
729
713
  if (__DEV__) {
730
714
  logger('Failed to load transfer codes from storage', error);
731
715
  }
716
+ // Mark as restored even on error to prevent retries
717
+ markRestored();
732
718
  }
733
719
  };
734
720
 
735
721
  loadTransferCodes();
736
- }, [storage, isStorageReady, logger, storageKeyPrefix]);
722
+ }, [storage, isStorageReady, isRestored, restoreFromStorage, markRestored, logger, storageKeyPrefix]);
737
723
 
738
- // Persist transfer codes to storage whenever they change
739
- const persistTransferCodes = useCallback(async () => {
740
- if (!storage) return;
741
-
742
- try {
743
- const codesToStore: Record<string, TransferCodeData> = {};
744
- transferCodesRef.current.forEach((value, key) => {
745
- codesToStore[key] = value;
746
- });
747
- await storage.setItem(TRANSFER_CODES_STORAGE_KEY, JSON.stringify(codesToStore));
748
- } catch (error) {
749
- if (__DEV__) {
750
- logger('Failed to persist transfer codes', error);
751
- }
752
- }
753
- }, [storage, logger]);
754
-
755
- // Cleanup old transfer codes (older than 15 minutes)
724
+ // Persist transfer codes to storage whenever store changes
725
+ const { transferCodes, activeTransferId } = useTransferCodesForPersistence();
756
726
  useEffect(() => {
757
- const cleanup = setInterval(async () => {
758
- const now = Date.now();
759
- const fifteenMinutes = 15 * 60 * 1000;
760
- let needsPersist = false;
761
-
762
- transferCodesRef.current.forEach((value, key) => {
763
- if (now - value.timestamp > fifteenMinutes) {
764
- transferCodesRef.current.delete(key);
765
- needsPersist = true;
766
- if (__DEV__) {
767
- logger('Cleaned up expired transfer code', { transferId: key });
768
- }
769
- }
770
- });
727
+ if (!storage || !isStorageReady || !isRestored) return;
771
728
 
772
- // Clear active transfer if it was deleted
773
- if (activeTransferIdRef.current && !transferCodesRef.current.has(activeTransferIdRef.current)) {
774
- activeTransferIdRef.current = null;
775
- if (storage) {
776
- try {
777
- await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
778
- } catch (error) {
779
- // Ignore storage errors
780
- }
729
+ const persistTransferCodes = async () => {
730
+ try {
731
+ await storage.setItem(TRANSFER_CODES_STORAGE_KEY, JSON.stringify(transferCodes));
732
+
733
+ if (activeTransferId) {
734
+ await storage.setItem(ACTIVE_TRANSFER_STORAGE_KEY, activeTransferId);
735
+ } else {
736
+ await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
737
+ }
738
+ } catch (error) {
739
+ if (__DEV__) {
740
+ logger('Failed to persist transfer codes', error);
781
741
  }
782
742
  }
743
+ };
783
744
 
784
- if (needsPersist) {
785
- await persistTransferCodes();
786
- }
745
+ persistTransferCodes();
746
+ }, [transferCodes, activeTransferId, storage, isStorageReady, isRestored, logger]);
747
+
748
+ // Cleanup expired transfer codes (every minute)
749
+ useEffect(() => {
750
+ const cleanup = setInterval(() => {
751
+ cleanupExpired();
787
752
  }, 60000); // Check every minute
788
753
 
789
754
  return () => clearInterval(cleanup);
790
- }, [logger, persistTransferCodes, storage]);
755
+ }, [cleanupExpired]);
791
756
 
792
- // Transfer code management functions
757
+ // Transfer code management functions using Zustand store
793
758
  const storeTransferCode = useCallback(async (transferId: string, code: string, sourceDeviceId: string | null, publicKey: string) => {
794
- const transferData: TransferCodeData = {
795
- code,
796
- sourceDeviceId,
797
- publicKey,
798
- timestamp: Date.now(),
799
- state: 'pending',
800
- };
801
-
802
- transferCodesRef.current.set(transferId, transferData);
803
- activeTransferIdRef.current = transferId;
804
-
805
- // Persist to storage
806
- await persistTransferCodes();
807
- if (storage) {
808
- try {
809
- await storage.setItem(ACTIVE_TRANSFER_STORAGE_KEY, transferId);
810
- } catch (error) {
811
- if (__DEV__) {
812
- logger('Failed to persist active transfer ID', error);
813
- }
814
- }
815
- }
759
+ storeTransferCodeStore(transferId, code, sourceDeviceId, publicKey);
816
760
 
817
761
  if (__DEV__) {
818
762
  logger('Stored transfer code', { transferId, sourceDeviceId, publicKey: publicKey.substring(0, 16) + '...' });
819
763
  }
820
- }, [logger, persistTransferCodes, storage]);
764
+ }, [logger, storeTransferCodeStore]);
821
765
 
822
766
  const getTransferCode = useCallback((transferId: string) => {
823
- return transferCodesRef.current.get(transferId) || null;
824
- }, []);
767
+ return getTransferCodeStore(transferId);
768
+ }, [getTransferCodeStore]);
825
769
 
826
770
  const updateTransferState = useCallback(async (transferId: string, state: 'pending' | 'completed' | 'failed') => {
827
- const transferData = transferCodesRef.current.get(transferId);
828
- if (transferData) {
829
- transferData.state = state;
830
- transferCodesRef.current.set(transferId, transferData);
831
-
832
- // Clear active transfer if completed or failed
833
- if (state === 'completed' || state === 'failed') {
834
- if (activeTransferIdRef.current === transferId) {
835
- activeTransferIdRef.current = null;
836
- if (storage) {
837
- try {
838
- await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
839
- } catch (error) {
840
- // Ignore storage errors
841
- }
842
- }
843
- }
844
- }
845
-
846
- await persistTransferCodes();
847
-
848
- if (__DEV__) {
849
- logger('Updated transfer state', { transferId, state });
850
- }
771
+ updateTransferStateStore(transferId, state);
772
+
773
+ if (__DEV__) {
774
+ logger('Updated transfer state', { transferId, state });
851
775
  }
852
- }, [logger, persistTransferCodes, storage]);
776
+ }, [logger, updateTransferStateStore]);
853
777
 
854
778
  const clearTransferCode = useCallback(async (transferId: string) => {
855
- transferCodesRef.current.delete(transferId);
856
-
857
- if (activeTransferIdRef.current === transferId) {
858
- activeTransferIdRef.current = null;
859
- if (storage) {
860
- try {
861
- await storage.removeItem(ACTIVE_TRANSFER_STORAGE_KEY);
862
- } catch (error) {
863
- // Ignore storage errors
864
- }
865
- }
866
- }
867
-
868
- await persistTransferCodes();
779
+ clearTransferCodeStore(transferId);
869
780
 
870
781
  if (__DEV__) {
871
782
  logger('Cleared transfer code', { transferId });
872
783
  }
873
- }, [logger, persistTransferCodes, storage]);
784
+ }, [logger, clearTransferCodeStore]);
874
785
 
875
786
  const refreshSessionsWithUser = useCallback(
876
787
  () => refreshSessions(userId),
@@ -889,6 +800,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
889
800
  logout().catch((remoteError) => logger('Failed to process remote sign out', remoteError));
890
801
  }, [logger, logout]);
891
802
 
803
+ // Check pending transfers when authenticated and online using TanStack Query
804
+ // Pass oxyServices and isAuthenticated directly to avoid circular dependency
805
+ const { data: pendingTransferResults } = useCheckPendingTransfers(oxyServices, isAuthenticated);
806
+
892
807
  const handleIdentityTransferComplete = useCallback(
893
808
  async (data: { transferId: string; sourceDeviceId: string; publicKey: string; transferCode?: string; completedAt: string }) => {
894
809
  try {
@@ -981,43 +896,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
981
896
  return;
982
897
  }
983
898
 
984
- // CRITICAL: Verify target device has identity before deleting on source device
985
- // This ensures we never lose the identity
986
- try {
987
- logger('Verifying target device has identity before deletion', {
988
- transferId: data.transferId,
989
- publicKey: data.publicKey.substring(0, 16) + '...',
990
- });
991
-
992
- // Verify target device has active session with matching public key
993
- const verifyResponse = await oxyServices.makeRequest<{ verified: boolean; hasActiveSession: boolean }>(
994
- 'GET',
995
- `/api/identity/verify-transfer?publicKey=${encodeURIComponent(data.publicKey)}`,
996
- undefined,
997
- { cache: false }
998
- );
999
-
1000
- if (!verifyResponse.verified || !verifyResponse.hasActiveSession) {
1001
- logger('Target device verification failed - identity will not be deleted', {
1002
- transferId: data.transferId,
1003
- verified: verifyResponse.verified,
1004
- hasActiveSession: verifyResponse.hasActiveSession,
1005
- });
1006
- await updateTransferState(data.transferId, 'failed');
1007
- toast.error('Transfer verification failed: Target device does not have active session. Identity will not be deleted.');
1008
- return;
1009
- }
1010
-
1011
- logger('Target device verification passed', {
1012
- transferId: data.transferId,
1013
- });
1014
- } catch (verifyError: any) {
1015
- // If verification fails, don't delete identity - it's safer to keep it
1016
- logger('Failed to verify target device - identity will not be deleted', verifyError);
1017
- await updateTransferState(data.transferId, 'failed');
1018
- toast.error('Transfer verification failed: Could not verify target device. Identity will not be deleted.');
1019
- return;
1020
- }
899
+ // NOTE: Target device verification already happened server-side when notifyTransferComplete was called
900
+ // The server verified that the target device is authenticated and has the matching public key
901
+ // Additional client-side verification is not necessary and would require source device authentication
902
+ // which may not be available. The existing checks (public key match, transfer code, device ID) are sufficient.
1021
903
 
1022
904
  logger('All transfer verifications passed, deleting identity from source device', {
1023
905
  transferId: data.transferId,
@@ -0,0 +1,168 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { useOxy } from '../context/OxyContext';
3
+ import { useTransferStore } from '../stores/transferStore';
4
+ import type { OxyServices } from '../../core';
5
+
6
+ /**
7
+ * Query keys for transfer-related queries
8
+ */
9
+ export const transferQueryKeys = {
10
+ all: ['transfers'] as const,
11
+ completion: (transferId: string) => ['transfers', 'completion', transferId] as const,
12
+ pending: () => ['transfers', 'pending'] as const,
13
+ };
14
+
15
+ /**
16
+ * Hook to check if a transfer was completed
17
+ * Only runs when authenticated and transferId is provided
18
+ */
19
+ export const useCheckTransferCompletion = (transferId: string | null, enabled: boolean = true) => {
20
+ const { oxyServices, isAuthenticated } = useOxy();
21
+
22
+ return useQuery({
23
+ queryKey: transferId ? transferQueryKeys.completion(transferId) : ['transfers', 'completion', 'null'],
24
+ queryFn: async () => {
25
+ if (!transferId || !oxyServices) {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ const response = await oxyServices.makeRequest<{
31
+ completed: boolean;
32
+ transferId?: string;
33
+ sourceDeviceId?: string;
34
+ publicKey?: string;
35
+ transferCode?: string;
36
+ completedAt?: string;
37
+ }>(
38
+ 'GET',
39
+ `/api/identity/check-transfer/${transferId}`,
40
+ undefined,
41
+ { cache: false }
42
+ );
43
+
44
+ return response;
45
+ } catch (error: any) {
46
+ // Handle 401 errors gracefully - don't throw, just return null
47
+ if (error?.status === 401 || error?.message?.includes('401') || error?.message?.includes('authentication')) {
48
+ if (__DEV__) {
49
+ console.warn('[useCheckTransferCompletion] Authentication required, skipping check');
50
+ }
51
+ return null;
52
+ }
53
+ throw error;
54
+ }
55
+ },
56
+ enabled: enabled && !!transferId && isAuthenticated && !!oxyServices,
57
+ staleTime: 30 * 1000, // 30 seconds - completion status doesn't change frequently
58
+ gcTime: 5 * 60 * 1000, // 5 minutes
59
+ retry: (failureCount, error: any) => {
60
+ // Don't retry on 401 errors
61
+ if (error?.status === 401 || error?.message?.includes('401')) {
62
+ return false;
63
+ }
64
+ return failureCount < 2;
65
+ },
66
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
67
+ });
68
+ };
69
+
70
+ /**
71
+ * Hook to check all pending transfers for completion
72
+ * Used when app comes back online
73
+ *
74
+ * This version accepts oxyServices and isAuthenticated as parameters to avoid
75
+ * circular dependency when used inside OxyContext
76
+ */
77
+ export const useCheckPendingTransfers = (
78
+ oxyServices?: OxyServices | null,
79
+ isAuthenticated?: boolean
80
+ ) => {
81
+ const getAllPendingTransfers = useTransferStore((state) => state.getAllPendingTransfers);
82
+ const pendingTransfers = getAllPendingTransfers();
83
+
84
+ return useQuery({
85
+ queryKey: [...transferQueryKeys.pending(), pendingTransfers.map(t => t.transferId).join(',')],
86
+ queryFn: async () => {
87
+ if (!oxyServices || pendingTransfers.length === 0) {
88
+ return [];
89
+ }
90
+
91
+ const results: Array<{
92
+ transferId: string;
93
+ completed: boolean;
94
+ data?: {
95
+ transferId?: string;
96
+ sourceDeviceId?: string;
97
+ publicKey?: string;
98
+ transferCode?: string;
99
+ completedAt?: string;
100
+ };
101
+ }> = [];
102
+
103
+ // Check each pending transfer
104
+ for (const { transferId, data } of pendingTransfers) {
105
+ try {
106
+ const response = await oxyServices.makeRequest<{
107
+ completed: boolean;
108
+ transferId?: string;
109
+ sourceDeviceId?: string;
110
+ publicKey?: string;
111
+ transferCode?: string;
112
+ completedAt?: string;
113
+ }>(
114
+ 'GET',
115
+ `/api/identity/check-transfer/${transferId}`,
116
+ undefined,
117
+ { cache: false }
118
+ );
119
+
120
+ if (response.completed && response.publicKey === data.publicKey) {
121
+ results.push({
122
+ transferId,
123
+ completed: true,
124
+ data: response,
125
+ });
126
+ } else {
127
+ results.push({
128
+ transferId,
129
+ completed: false,
130
+ });
131
+ }
132
+ } catch (error: any) {
133
+ // Handle 401 errors gracefully - skip this transfer
134
+ if (error?.status === 401 || error?.message?.includes('401') || error?.message?.includes('authentication')) {
135
+ if (__DEV__) {
136
+ console.warn(`[useCheckPendingTransfers] Authentication required for transfer ${transferId}, skipping`);
137
+ }
138
+ results.push({
139
+ transferId,
140
+ completed: false,
141
+ });
142
+ continue;
143
+ }
144
+ // For other errors, mark as not completed
145
+ results.push({
146
+ transferId,
147
+ completed: false,
148
+ });
149
+ }
150
+ }
151
+
152
+ return results;
153
+ },
154
+ enabled: (isAuthenticated ?? false) && !!oxyServices && pendingTransfers.length > 0,
155
+ staleTime: 30 * 1000, // 30 seconds
156
+ gcTime: 5 * 60 * 1000, // 5 minutes
157
+ retry: false, // Don't retry - we'll check again on next reconnect
158
+ });
159
+ };
160
+
161
+ /**
162
+ * Hook version that uses useOxy() - for use outside OxyContext
163
+ */
164
+ export const useCheckPendingTransfersWithContext = () => {
165
+ const { oxyServices, isAuthenticated } = useOxy();
166
+ return useCheckPendingTransfers(oxyServices, isAuthenticated);
167
+ };
168
+