diff --git a/app/src/navigation/account.ts b/app/src/navigation/account.ts index 7b1656dd96..2196da1885 100644 --- a/app/src/navigation/account.ts +++ b/app/src/navigation/account.ts @@ -13,6 +13,7 @@ import { import { HeadlessNavForEuclid } from '@/components/navbar/HeadlessNavForEuclid'; import AccountRecoveryChoiceScreen from '@/screens/account/recovery/AccountRecoveryChoiceScreen'; import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScreen'; +import AccountRestoreScreen from '@/screens/account/recovery/AccountRestoreScreen'; import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen'; import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen'; import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen'; @@ -81,7 +82,18 @@ const accountScreens = { screens: {}, }, }, - + AccountRestore: { + screen: AccountRestoreScreen, + options: { + title: 'Restore Account', + headerStyle: { + backgroundColor: white, + }, + headerTitleStyle: { + color: black, + }, + } as NativeStackNavigationOptions, + }, ShowRecoveryPhrase: { screen: ShowRecoveryPhraseScreen, options: IS_EUCLID_ENABLED diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index 8ce305cffb..012a972460 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -54,6 +54,7 @@ import { inferDocumentCategory, } from '@selfxyz/common/utils'; import { parseCertificateSimple } from '@selfxyz/common/utils/certificate_parsing/parseCertificateSimple'; +import { isUserRegisteredWithAlternativeCSCA } from '@selfxyz/common/utils/passports/validate'; import type { AadhaarData, DocumentCatalog, @@ -887,3 +888,61 @@ export const getAllDocumentsDirectlyFromKeychain = async (): Promise<{ return allDocs; }; + +/** + * Verifies if the secret is valid for all documents and marks the status as registered. + * This can be used when a document registeration fails mid-process and we want to restore the status of the document, + * instead of starting the registration process again (MRZ, NFC, etc). + */ +export const restoreSecretForAllDocuments = async ( + selfClient: SelfClient, + secret: string, +): Promise => { + const catalog = await selfClient.loadDocumentCatalog(); + const useProtocolStore = selfClient.useProtocolStore; + const successDocuments: string[] = []; + if (!useProtocolStore.getState().aadhaar.public_keys) { + await useProtocolStore.getState().aadhaar.fetch_all('prod'); + } + for (const document of catalog.documents) { + try { + if (!document.isRegistered && document.mock === false) { + const data = await selfClient.loadDocumentById(document.id); + if (data) { + const { isRegistered: docIsRegistered, csca: docCsca } = + await isUserRegisteredWithAlternativeCSCA(data, secret as string, { + getCommitmentTree(docCategory) { + return useProtocolStore.getState()[docCategory].commitment_tree; + }, + getAltCSCA(docCategory) { + if (docCategory === 'aadhaar') { + const publicKeys = + useProtocolStore.getState().aadhaar.public_keys; + // Convert string[] to Record format expected by AlternativeCSCA + return publicKeys + ? Object.fromEntries(publicKeys.map(key => [key, key])) + : {}; + } + + return useProtocolStore.getState()[docCategory] + .alternative_csca; + }, + }); + + if (!docIsRegistered) { + continue; + } + + if (docIsRegistered && docCsca && isMRZDocument(data)) { + await reStorePassportDataWithRightCSCA(data, docCsca as string); + } + await updateDocumentRegistrationState(document.id, true); + successDocuments.push(document.id); + } + } + } catch (error) { + console.error('Error restoring document:', error); + } + } + return successDocuments; +}; diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 34cf31c678..43ee7ed50b 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -200,7 +200,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { addListener(SdkEvents.PROVING_ACCOUNT_RECOVERY_REQUIRED, () => { if (navigationRef.isReady()) { - navigationRef.navigate('AccountRecoveryChoice'); + navigationRef.navigate('AccountRecoveryChoice', { + restoreAllDocuments: false, + }); } }); diff --git a/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx b/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx index 67598ea3e1..8a2c8e8770 100644 --- a/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx +++ b/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useState } from 'react'; import { Separator, View, XStack, YStack } from 'tamagui'; +import type { StaticScreenProps } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -36,12 +37,20 @@ import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider'; import { loadPassportData, reStorePassportDataWithRightCSCA, + restoreSecretForAllDocuments, } from '@/providers/passportDataProvider'; import { STORAGE_NAME, useBackupMnemonic } from '@/services/cloud-backup'; import { useSettingStore } from '@/stores/settingStore'; import type { Mnemonic } from '@/types/mnemonic'; -const AccountRecoveryChoiceScreen: React.FC = () => { +type AccountRecoveryChoiceScreenProps = StaticScreenProps<{ + restoreAllDocuments?: boolean; +}>; + +const AccountRecoveryChoiceScreen: React.FC< + AccountRecoveryChoiceScreenProps +> = ({ route }) => { + const restoreAllDocuments = route.params?.restoreAllDocuments ?? false; const selfClient = useSelfClient(); const { useProtocolStore } = selfClient; const { trackEvent } = useSelfClient(); @@ -63,13 +72,29 @@ const AccountRecoveryChoiceScreen: React.FC = () => { // ); const onRestoreFromCloudNext = useHapticNavigation('AccountVerifiedSuccess'); - const onEnterRecoveryPress = useHapticNavigation('RecoverWithPhrase'); + const onEnterRecoveryPress = useHapticNavigation('RecoverWithPhrase', { + params: { + restoreAllDocuments: restoreAllDocuments, + }, + }); // DISABLED FOR NOW: Turnkey functionality // useEffect(() => { // refreshWallets(); // }, [refreshWallets]); + const handleRestoreFailed = useCallback( + (reason: string, hasCSCA: boolean) => { + console.warn('Failed to restore account'); + trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED, { + reason: reason, + hasCSCA: hasCSCA, + }); + navigation.navigate({ name: 'Home', params: {} }); + }, + [trackEvent, navigation], + ); + const restoreAccountFlow = useCallback( async ( mnemonic: Mnemonic, @@ -100,54 +125,61 @@ const AccountRecoveryChoiceScreen: React.FC = () => { return false; } - const passportDataParsed = JSON.parse(passportData); - - const { isRegistered, csca } = - await isUserRegisteredWithAlternativeCSCA( - passportDataParsed, + if (restoreAllDocuments) { + const successDocuments = await restoreSecretForAllDocuments( + selfClient, secret as string, - { - getCommitmentTree(docCategory) { - return useProtocolStore.getState()[docCategory].commitment_tree; - }, - getAltCSCA(docCategory) { - if (docCategory === 'aadhaar') { - const publicKeys = - useProtocolStore.getState().aadhaar.public_keys; - // Convert string[] to Record format expected by AlternativeCSCA - return publicKeys - ? Object.fromEntries(publicKeys.map(key => [key, key])) - : {}; - } + ); + if (successDocuments.length === 0) { + handleRestoreFailed('all_documents_not_registered', false); + setRestoring(false); + return false; + } + } else { + const passportDataParsed = JSON.parse(passportData); - return useProtocolStore.getState()[docCategory] - .alternative_csca; + const { isRegistered, csca } = + await isUserRegisteredWithAlternativeCSCA( + passportDataParsed, + secret as string, + { + getCommitmentTree(docCategory) { + return useProtocolStore.getState()[docCategory] + .commitment_tree; + }, + getAltCSCA(docCategory) { + if (docCategory === 'aadhaar') { + const publicKeys = + useProtocolStore.getState().aadhaar.public_keys; + // Convert string[] to Record format expected by AlternativeCSCA + return publicKeys + ? Object.fromEntries(publicKeys.map(key => [key, key])) + : {}; + } + + return useProtocolStore.getState()[docCategory] + .alternative_csca; + }, }, - }, - ); - if (!isRegistered) { - console.warn( - 'Secret provided did not match a registered ID. Please try again.', - ); - trackEvent( - BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED, - { - reason: 'document_not_registered', - hasCSCA: !!csca, - }, + ); + if (!isRegistered) { + console.warn( + 'Secret provided did not match a registered ID. Please try again.', + ); + handleRestoreFailed('document_not_registered', !!csca); + setRestoring(false); + return false; + } + if (isCloudRestore && !cloudBackupEnabled) { + toggleCloudBackupEnabled(); + } + await reStorePassportDataWithRightCSCA( + passportDataParsed, + csca as string, ); - navigation.navigate({ name: 'Home', params: {} }); - setRestoring(false); - return false; + await markCurrentDocumentAsRegistered(selfClient); } - if (isCloudRestore && !cloudBackupEnabled) { - toggleCloudBackupEnabled(); - } - await reStorePassportDataWithRightCSCA( - passportDataParsed, - csca as string, - ); - await markCurrentDocumentAsRegistered(selfClient); + trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS); trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED); onRestoreFromCloudNext(); @@ -164,14 +196,16 @@ const AccountRecoveryChoiceScreen: React.FC = () => { } }, [ - trackEvent, restoreAccountFromMnemonic, - cloudBackupEnabled, + restoreAllDocuments, + trackEvent, onRestoreFromCloudNext, navigation, - toggleCloudBackupEnabled, - useProtocolStore, selfClient, + handleRestoreFailed, + cloudBackupEnabled, + useProtocolStore, + toggleCloudBackupEnabled, ], ); diff --git a/app/src/screens/account/recovery/AccountRecoveryScreen.tsx b/app/src/screens/account/recovery/AccountRecoveryScreen.tsx index 49b7fab3c4..aebc423f0c 100644 --- a/app/src/screens/account/recovery/AccountRecoveryScreen.tsx +++ b/app/src/screens/account/recovery/AccountRecoveryScreen.tsx @@ -23,7 +23,11 @@ import useHapticNavigation from '@/hooks/useHapticNavigation'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; const AccountRecoveryScreen: React.FC = () => { - const onRestoreAccountPress = useHapticNavigation('AccountRecoveryChoice'); + const onRestoreAccountPress = useHapticNavigation('AccountRecoveryChoice', { + params: { + restoreAllDocuments: true, + }, + }); const onCreateAccountPress = useHapticNavigation('CloudBackupSettings', { params: { nextScreen: 'SaveRecoveryPhrase', diff --git a/app/src/screens/account/recovery/AccountRestoreScreen.tsx b/app/src/screens/account/recovery/AccountRestoreScreen.tsx new file mode 100644 index 0000000000..7b92e387d9 --- /dev/null +++ b/app/src/screens/account/recovery/AccountRestoreScreen.tsx @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useCallback } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { YStack } from 'tamagui'; + +import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; +import { PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components'; +import { BackupEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; +import { + black, + blue600, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import RestoreAccountSvg from '@/assets/icons/restore_account.svg'; +import useHapticNavigation from '@/hooks/useHapticNavigation'; + +const AccountRestoreScreen: React.FC = () => { + const navigateToAccountRecovery = useHapticNavigation( + 'AccountRecoveryChoice', + { + params: { + restoreAllDocuments: true, + }, + }, + ); + const navigateToCountryPicker = useHapticNavigation('CountryPicker'); + const { loadDocumentCatalog } = useSelfClient(); + + const hasUnregisteredDocuments = useCallback(async () => { + const catalog = await loadDocumentCatalog(); + return catalog.documents.some( + doc => !doc.isRegistered && doc.mock === false, + ); + }, [loadDocumentCatalog]); + + const onRestoreAccountPress = useCallback(async () => { + const value = await hasUnregisteredDocuments(); + if (value) { + navigateToAccountRecovery(); + } else { + navigateToCountryPicker(); + } + }, [ + hasUnregisteredDocuments, + navigateToAccountRecovery, + navigateToCountryPicker, + ]); + + return ( + + + + + + + + + Restore your account + + Restore your Self account using your recovery phrase or cloud + backup. + + + + + + Restore my account + + + + + + ); +}; + +const styles = StyleSheet.create({ + content: { + width: '100%', + alignItems: 'center', + gap: 30, + }, + iconContainer: { + width: 120, + height: 120, + borderRadius: 32, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: blue600, + }, + descriptionContainer: { + width: '100%', + gap: 12, + alignItems: 'center', + }, + title: { + width: '100%', + fontSize: 28, + letterSpacing: 1, + fontFamily: advercase, + color: black, + textAlign: 'center', + }, + description: { + width: '100%', + fontSize: 18, + fontWeight: '500', + fontFamily: dinot, + color: black, + textAlign: 'center', + }, + optionsContainer: { + width: '100%', + gap: 10, + }, +}); + +export default AccountRestoreScreen; diff --git a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx index 3f3e5e6381..2d8642059b 100644 --- a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx +++ b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx @@ -7,10 +7,12 @@ import React, { useCallback, useState } from 'react'; import { Keyboard, StyleSheet } from 'react-native'; import { Text, TextArea, View, XStack, YStack } from 'tamagui'; import Clipboard from '@react-native-clipboard/clipboard'; +import type { StaticScreenProps } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { isUserRegisteredWithAlternativeCSCA } from '@selfxyz/common/utils/passports/validate'; +import { isMRZDocument } from '@selfxyz/common/utils/types'; import { markCurrentDocumentAsRegistered, useSelfClient, @@ -35,9 +37,16 @@ import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider'; import { loadPassportData, reStorePassportDataWithRightCSCA, + restoreSecretForAllDocuments, } from '@/providers/passportDataProvider'; -const RecoverWithPhraseScreen: React.FC = () => { +type RecoverWithPhraseScreenProps = StaticScreenProps<{ + restoreAllDocuments?: boolean; +}>; +const RecoverWithPhraseScreen: React.FC = ({ + route, +}) => { + const restoreAllDocuments = route.params?.restoreAllDocuments ?? false; const navigation = useNavigation>(); const selfClient = useSelfClient(); @@ -54,6 +63,19 @@ const RecoverWithPhraseScreen: React.FC = () => { } }, []); + const handleRestoreFailed = useCallback( + (reason: string, hasCSCA: boolean) => { + console.warn('Failed to restore account'); + trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED, { + reason: reason, + hasCSCA, + }); + navigation.navigate({ name: 'Home', params: {} }); + setRestoring(false); + }, + [trackEvent, navigation], + ); + const restoreAccount = useCallback(async () => { try { setRestoring(true); @@ -88,47 +110,57 @@ const RecoverWithPhraseScreen: React.FC = () => { setRestoring(false); return; } - const passportDataParsed = JSON.parse(passportData); - - const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA( - passportDataParsed, - secret as string, - { - getCommitmentTree(docCategory) { - return useProtocolStore.getState()[docCategory].commitment_tree; - }, - getAltCSCA(docCategory) { - if (docCategory === 'aadhaar') { - const publicKeys = - useProtocolStore.getState().aadhaar.public_keys; - // Convert string[] to Record format expected by AlternativeCSCA - return publicKeys - ? Object.fromEntries(publicKeys.map(key => [key, key])) - : {}; - } - - return useProtocolStore.getState()[docCategory].alternative_csca; - }, - }, - ); - if (!isRegistered) { - console.warn( - 'Secret provided did not match a registered passport. Please try again.', - ); - trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED, { - reason: 'document_not_registered', - hasCSCA: !!csca, - }); - navigation.navigate({ name: 'Home', params: {} }); - setRestoring(false); - return; - } - if (csca) { - await reStorePassportDataWithRightCSCA(passportDataParsed, csca); + if (restoreAllDocuments) { + const successDocuments = await restoreSecretForAllDocuments( + selfClient, + secret as string, + ); + if (successDocuments.length === 0) { + handleRestoreFailed('all_documents_not_registered', false); + return; + } + } else { + const passportDataParsed = JSON.parse(passportData); + + const { isRegistered, csca } = + await isUserRegisteredWithAlternativeCSCA( + passportDataParsed, + secret as string, + { + getCommitmentTree(docCategory) { + return useProtocolStore.getState()[docCategory].commitment_tree; + }, + getAltCSCA(docCategory) { + if (docCategory === 'aadhaar') { + const publicKeys = + useProtocolStore.getState().aadhaar.public_keys; + // Convert string[] to Record format expected by AlternativeCSCA + return publicKeys + ? Object.fromEntries(publicKeys.map(key => [key, key])) + : {}; + } + + return useProtocolStore.getState()[docCategory] + .alternative_csca; + }, + }, + ); + if (!isRegistered) { + console.warn( + 'Secret provided did not match a registered passport. Please try again.', + ); + handleRestoreFailed('document_not_registered', !!csca); + return; + } + + if (csca) { + await reStorePassportDataWithRightCSCA(passportDataParsed, csca); + } + + await markCurrentDocumentAsRegistered(selfClient); } - await markCurrentDocumentAsRegistered(selfClient); setRestoring(false); trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED); navigation.navigate('AccountVerifiedSuccess'); @@ -141,9 +173,11 @@ const RecoverWithPhraseScreen: React.FC = () => { navigation.navigate({ name: 'Home', params: {} }); } }, [ + handleRestoreFailed, mnemonic, navigation, restoreAccountFromMnemonic, + restoreAllDocuments, selfClient, trackEvent, useProtocolStore, diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index ab415fa97e..b19a37d62f 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -77,6 +77,7 @@ const routes = [Data, 'View document info', 'DocumentDataInfo'], [Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'], [Cloud, 'Cloud backup', 'CloudBackupSettings'], + [Cloud, 'Restore account', 'AccountRestore'], [Feedback, 'Send feedback', 'email_feedback'], [ShareIcon, 'Share Self app', 'share'], [ diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 3ed68c9627..0d8b9f817c 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -38,6 +38,7 @@ describe('navigation', () => { 'AadhaarUploadSuccess', 'AccountRecovery', 'AccountRecoveryChoice', + 'AccountRestore', 'AccountVerifiedSuccess', 'CloudBackupSettings', 'ComingSoon',