feat: add a recovery phrase screen (#24)

This commit is contained in:
Nicolas Brugneaux
2025-02-05 16:55:02 +01:00
committed by GitHub
parent 506ef1b25d
commit 7bb592d6f9
10 changed files with 290 additions and 69 deletions

View File

@@ -23,6 +23,7 @@
"@openpassport/zk-kit-lean-imt": "^0.0.6",
"@openpassport/zk-kit-smt": "^0.0.1",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-clipboard/clipboard": "^1.16.1",
"@react-native-community/netinfo": "^11.3.1",
"@react-navigation/elements": "^2.2.5",
"@react-navigation/native": "^7.0.14",
@@ -55,6 +56,7 @@
"react-native-gesture-handler": "^2.22.1",
"react-native-get-random-values": "^1.11.0",
"react-native-keychain": "^8.2.0",
"react-native-localize": "^3.4.1",
"react-native-nfc-manager": "^3.15.1",
"react-native-passport-reader": "^1.0.3",
"react-native-safe-area-context": "^5.1.0",

View File

@@ -25,6 +25,8 @@ import PassportOnboardingScreen from './screens/Onboarding/PassportOnboardingScr
import ProveScreen from './screens/ProveFlow/ProveScreen';
import ValidProofScreen from './screens/ProveFlow/ValidProofScreen';
import QRCodeViewFinderScreen from './screens/ProveFlow/ViewFinder';
import AccountRecoveryScreen from './screens/Settings/AccountRecoveryScreen';
import ShowRecoveryPhraseScreen from './screens/Settings/ShowRecoveryPhraseScreen';
import WrongProofScreen from './screens/ProveFlow/WrongProofScreen';
import SettingsScreen from './screens/SettingsScreen';
import SplashScreen from './screens/SplashScreen';
@@ -209,6 +211,18 @@ const RootStack = createStackNavigator({
screens: {},
},
},
AccountRecovery: {
screen: AccountRecoveryScreen,
options: {
headerShown: false,
},
},
ShowRecoveryPhrase: {
screen: ShowRecoveryPhraseScreen,
options: {
headerShown: false,
},
},
},
});

View File

@@ -0,0 +1,105 @@
import React, { useCallback, useState } from 'react';
import Clipboard from '@react-native-clipboard/clipboard';
import { Button, Text, XStack, YStack } from 'tamagui';
import {
black,
slate50,
slate200,
slate300,
slate500,
teal500,
white,
} from '../utils/colors';
interface MnemonicProps {
words?: string[];
revealWords?: boolean;
}
interface WordPill {
index: number;
word: string;
}
const WordPill = ({ index, word }: WordPill) => {
return (
<XStack
key={index}
borderColor={slate300}
backgroundColor={white}
borderWidth="$0.5"
borderRadius="$2"
py="$1"
px="$2"
gap="$2"
>
<Text color={slate300} fontSize={14} fontWeight={500}>
{index}
</Text>
<Text color={slate500} fontSize={14} fontWeight={500}>
{word}
</Text>
</XStack>
);
};
const REDACTED = new Array(24).fill(' '.repeat(4));
const Mnemonic = ({ words = REDACTED }: MnemonicProps) => {
const [revealWords, setRevealWords] = useState(false);
const [copied, setCopied] = useState(false);
const copyToClipboardOrReveal = useCallback(() => {
if (!revealWords) {
return setRevealWords(previous => !previous);
}
Clipboard.setString(words.join(' '));
setCopied(true);
setTimeout(() => setCopied(false), 2500);
}, [words, revealWords]);
return (
<YStack position="relative" alignItems="stretch" gap={0}>
<XStack
borderColor={slate200}
backgroundColor={slate50}
background="blue"
borderWidth="$1"
borderBottomWidth={0}
borderTopLeftRadius="$5"
borderTopRightRadius="$5"
gap="$2.5"
p="$4"
flexWrap="wrap"
>
{(revealWords ? words : REDACTED).map((word, i) => (
<WordPill key={i} word={word} index={i} />
))}
</XStack>
<XStack
borderTopColor={slate200}
borderTopWidth="$1"
justifyContent="center"
alignItems="stretch"
>
<Button
unstyled
color={revealWords ? (copied ? black : white) : black}
borderColor={revealWords ? (copied ? teal500 : black) : slate200}
backgroundColor={revealWords ? (copied ? teal500 : black) : slate50}
borderWidth="$1"
borderTopWidth={0}
borderBottomLeftRadius="$5"
borderBottomRightRadius="$5"
py="$2"
onPress={copyToClipboardOrReveal}
width="100%"
textAlign="center"
>
{revealWords
? `${copied ? 'COPIED' : 'COPY'} TO CLIPBOARD`
: 'TAP TO REVEAL'}
</Button>
</XStack>
</YStack>
);
};
export default Mnemonic;

View File

@@ -1,68 +0,0 @@
import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Image, YStack } from 'tamagui';
import { PrimaryButton } from '../components/buttons/PrimaryButton';
import Logo from '../images/logo.png';
import { ExpandableBottomLayout } from '../layouts/ExpandableBottomLayout';
import { slate50, slate100, slate500, slate700 } from '../utils/colors';
interface AccountRecoveryScreenProps {}
const AccountRecoveryScreen: React.FC<AccountRecoveryScreenProps> = ({}) => {
const navigation = useNavigation();
return (
<ExpandableBottomLayout.Layout>
<ExpandableBottomLayout.TopSection>
<Image src={Logo} />
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection>
<YStack gap="$2.5">
<Text style={styles.subheader}>
By continuing, you certify that this passport belongs to you and is
not stolen or forged.
</Text>
<PrimaryButton onPress={() => navigation.navigate('TODO: restore')}>
Restore my account
</PrimaryButton>
<PrimaryButton
onPress={() => navigation.navigate('PassportOnboarding')}
>
Create new account
</PrimaryButton>
</YStack>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);
};
export default AccountRecoveryScreen;
const styles = StyleSheet.create({
subheader: {
color: slate700,
// fontWeight: '500',
fontSize: 20,
lineHeight: 26,
textAlign: 'center',
},
link: {
textDecorationLine: 'underline',
},
notice: {
paddingTop: 10,
paddingBottom: 10,
paddingLeft: 20,
paddingRight: 20,
backgroundColor: slate50,
borderColor: slate100,
borderWidth: 1,
borderStyle: 'solid',
color: slate500,
textAlign: 'center',
lineHeight: 18,
},
});

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { useNavigation } from '@react-navigation/native';
import { View, YStack } from 'tamagui';
import { PrimaryButton } from '../../components/buttons/PrimaryButton';
import { SecondaryButton } from '../../components/buttons/SecondaryButton';
import Description from '../../components/typography/Description';
import { Title } from '../../components/typography/Title';
import RestoreAccountSvg from '../../images/icons/restore_account.svg';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { slate600, white } from '../../utils/colors';
interface AccountRecoveryScreenProps {}
const AccountRecoveryScreen: React.FC<AccountRecoveryScreenProps> = ({}) => {
const navigation = useNavigation();
return (
<ExpandableBottomLayout.Layout>
<ExpandableBottomLayout.TopSection>
<View borderColor={slate600} borderWidth="$1" borderRadius="$10" p="$5">
<RestoreAccountSvg height={80} width={80} color={white} />
</View>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection>
<YStack alignItems="center" gap="$2.5" pb="$2.5">
<Title>Restore your Self ID account</Title>
<Description>
By continuing, you certify that this passport belongs to you and is
not stolen or forged.
</Description>
<YStack gap="$2.5" width="100%" pt="$6">
<PrimaryButton onPress={() => navigation.navigate('TODO: restore')}>
Restore my account
</PrimaryButton>
<SecondaryButton
onPress={() => navigation.navigate('ShowRecoveryPhrase')}
>
Create new account
</SecondaryButton>
</YStack>
</YStack>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);
};
export default AccountRecoveryScreen;

View File

@@ -0,0 +1,84 @@
import React, { useCallback, useEffect, useState } from 'react';
import { findBestLanguageTag } from 'react-native-localize';
import { ethers } from 'ethers';
import { YStack } from 'tamagui';
import Mnemonic from '../../components/Mnemonic';
import { PrimaryButton } from '../../components/buttons/PrimaryButton';
import { SecondaryButton } from '../../components/buttons/SecondaryButton';
import { Caption } from '../../components/typography/Caption';
import Description from '../../components/typography/Description';
import { Title } from '../../components/typography/Title';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { slate400 } from '../../utils/colors';
import { loadSecretOrCreateIt } from '../../utils/keychain';
interface ShowRecoveryPhraseScreenProps {}
const ShowRecoveryPhraseScreen: React.FC<
ShowRecoveryPhraseScreenProps
> = ({}) => {
const [mnemonic, setMnemonic] = useState<string[]>();
const loadPassword = useCallback(async () => {
const privKey = await loadSecretOrCreateIt();
const { languageTag } = findBestLanguageTag(
Object.keys(ethers.wordlists),
) || { languageTag: 'en' };
const words = ethers.Mnemonic.entropyToPhrase(
privKey,
ethers.wordlists[languageTag],
);
setMnemonic(words.trim().split(' '));
}, []);
useEffect(() => {
loadPassword();
}, []);
return (
<ExpandableBottomLayout.Layout>
<ExpandableBottomLayout.BottomSection>
<YStack
alignItems="center"
gap="$2.5"
pb="$2.5"
height="100%"
justifyContent="flex-end"
>
<Title>Save your recovery phrase</Title>
<Description>
This phrase is the only way to recover your account. Keep it secret,
keep it safe.
</Description>
<Mnemonic words={mnemonic} revealWords={false} />
<YStack gap="$2.5" width="100%" pt="$6" alignItems="center">
<Caption color={slate400}>
You can reveal your recovery phrase in settings.
</Caption>
<PrimaryButton
onPress={
() => undefined /* TODO: navigate to icloud backup screen */
}
>
Enable iCloud Back up
</PrimaryButton>
<SecondaryButton
onPress={
() => undefined /* TODO: show alert to confirm then navigate */
}
>
Skip making a back up
</SecondaryButton>
</YStack>
</YStack>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);
};
export default ShowRecoveryPhraseScreen;

View File

@@ -16,7 +16,7 @@ const SplashScreen: React.FC<SplashScreenProps> = ({}) => {
useEffect(() => {
if (userLoaded) {
if (passportData && passportData.dg2Hash) {
if (passportData) {
navigation.navigate('Home');
} else {
navigation.navigate('Launch');

View File

@@ -15,6 +15,7 @@ export const slate800 = '#1E293B';
export const sky500 = '#0EA5E9';
export const green500 = '#22C55E';
export const red500 = '#EF4444';
export const teal300 = '#5EEAD4';
export const teal500 = '#5EEAD4';
export const neutral400 = '#A3A3A3';
export const neutral700 = '#404040';

View File

@@ -2509,6 +2509,23 @@ __metadata:
languageName: node
linkType: hard
"@react-native-clipboard/clipboard@npm:^1.16.1":
version: 1.16.1
resolution: "@react-native-clipboard/clipboard@npm:1.16.1"
peerDependencies:
react: ">= 16.9.0"
react-native: ">= 0.61.5"
react-native-macos: ">= 0.61.0"
react-native-windows: ">= 0.61.0"
peerDependenciesMeta:
react-native-macos:
optional: true
react-native-windows:
optional: true
checksum: 10c0/765e1c140c34fbfe30a1e7e0657cc2a325a103a4fcaf757994dff3d8877beb8c4f0b915eac5542ada5910560ce2cfe624cd23081302dd6d6bc76354ff0d98d5e
languageName: node
linkType: hard
"@react-native-community/cli-clean@npm:14.1.0":
version: 14.1.0
resolution: "@react-native-community/cli-clean@npm:14.1.0"
@@ -12177,6 +12194,7 @@ __metadata:
"@openpassport/zk-kit-lean-imt": "npm:^0.0.6"
"@openpassport/zk-kit-smt": "npm:^0.0.1"
"@react-native-async-storage/async-storage": "npm:^1.23.1"
"@react-native-clipboard/clipboard": "npm:^1.16.1"
"@react-native-community/cli": "npm:^14.1.1"
"@react-native-community/netinfo": "npm:^11.3.1"
"@react-native/babel-preset": "npm:0.75.4"
@@ -12233,6 +12251,7 @@ __metadata:
react-native-gesture-handler: "npm:^2.22.1"
react-native-get-random-values: "npm:^1.11.0"
react-native-keychain: "npm:^8.2.0"
react-native-localize: "npm:^3.4.1"
react-native-nfc-manager: "npm:^3.15.1"
react-native-passport-reader: "npm:^1.0.3"
react-native-safe-area-context: "npm:^5.1.0"
@@ -12813,6 +12832,20 @@ __metadata:
languageName: node
linkType: hard
"react-native-localize@npm:^3.4.1":
version: 3.4.1
resolution: "react-native-localize@npm:3.4.1"
peerDependencies:
react: ">=18.1.0"
react-native: ">=0.70.0"
react-native-macos: ">=0.70.0"
peerDependenciesMeta:
react-native-macos:
optional: true
checksum: 10c0/5da5ab3e3cd882878ce4336773d5cadb0b636bab40e3c72cd2d41e461b771e8afd6a92bb517e2afe57b83c0d8476634b4d5cedfc3ed41c0713778f71c867697c
languageName: node
linkType: hard
"react-native-nfc-manager@npm:^3.15.1":
version: 3.16.1
resolution: "react-native-nfc-manager@npm:3.16.1"