diff --git a/app/app/HapticFeedback.ts b/app/app/HapticFeedback.ts index 1075d35..78d33db 100644 --- a/app/app/HapticFeedback.ts +++ b/app/app/HapticFeedback.ts @@ -23,6 +23,8 @@ interface HapticFeedbackOptions { impactAsync(style?: ImpactFeedbackStyle): Promise; } +export { ImpactFeedbackStyle, NotificationFeedbackType }; + const HapticFeedbackContext = createContext({} as any); /** diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index 67ce67f..41549ed 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -77,6 +77,10 @@ function AppContainer(): ReactElement | null { > + + {/* For key related screens, they're set to full screen modal to force closing of modal to be a more deliberate action. */} + + diff --git a/app/app/about/design.tsx b/app/app/about/design.tsx index c5c6ab5..892e991 100644 --- a/app/app/about/design.tsx +++ b/app/app/about/design.tsx @@ -76,6 +76,14 @@ export default function DesignSystemPage(): ReactElement { Font-Mono text-sm text-zinc-200 font-mono + + Error, something went wrong. + text-base font-bold text-red-600 + + + Success! + text-base font-bold text-green-600 +
@@ -181,7 +189,7 @@ export default function DesignSystemPage(): ReactElement { - SECTION + SECTION ); diff --git a/app/app/keys/settings.tsx b/app/app/keys/settings.tsx deleted file mode 100644 index ba0e0d4..0000000 --- a/app/app/keys/settings.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Stack } from 'expo-router'; -import { ReactElement } from 'react'; - -export default function KeySettingsPage(): ReactElement { - return ( - <> - - - ); -} diff --git a/app/app/keys/settings/_layout.tsx b/app/app/keys/settings/_layout.tsx new file mode 100644 index 0000000..2de556b --- /dev/null +++ b/app/app/keys/settings/_layout.tsx @@ -0,0 +1,27 @@ +import { Stack, useRouter } from 'expo-router'; +import { ReactElement } from 'react'; +import { useTailwind } from 'tailwind-rn'; + +import { StackHeaderClose } from '../../../components/StackHeader'; + +export default function KeySettingLayout(): ReactElement { + const tailwind = useTailwind(); + const router = useRouter(); + + return ( + null, + headerRight: () => ( + { + router.push('/tabs/settings'); + }} + /> + ), + }} + /> + ); +} diff --git a/app/app/keys/settings/index.tsx b/app/app/keys/settings/index.tsx new file mode 100644 index 0000000..4702c17 --- /dev/null +++ b/app/app/keys/settings/index.tsx @@ -0,0 +1,153 @@ +import { Stack, useRouter } from 'expo-router'; +import { PropsWithChildren, ReactElement } from 'react'; +import { SafeAreaView, ScrollView, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { NotificationFeedbackType, useHaptic } from '../../HapticFeedback'; +import { IconSet, IconSetName } from '../../IconSet'; + +export default function KeySettingsPage(): ReactElement { + const tailwind = useTailwind(); + // TODO(fuxingloh): setting to toggle show your mnemonic + + return ( + <> + + + + SECURITY + + BIP32 & BIP39 + + + + + + + + ); +} + +export function KeychainSettingRowBip32Scheme(): ReactElement { + const tailwind = useTailwind(); + + return ( + + m/0'/0'/0'/0'/i' + + ); +} + +export function KeychainSettingRowBip32Hardened(): ReactElement { + const tailwind = useTailwind(); + const haptic = useHaptic(); + + return ( + haptic.notificationAsync(NotificationFeedbackType.Error)} + > + + + ); +} + +export function KeychainSettingRowBip39Language(): ReactElement { + const tailwind = useTailwind(); + + return ( + + English + + ); +} + +export function KeychainSettingRowMaxLength(): ReactElement { + const tailwind = useTailwind(); + + return ( + + 1,000 Keys + + ); +} + +export function KeychainSettingRowPasscode(): ReactElement { + const tailwind = useTailwind(); + const haptic = useHaptic(); + const router = useRouter(); + + return ( + { + await haptic.selectionAsync(); + await router.push('keys/settings/passcode'); + }} + > + + {[0, 1, 2, 3, 4, 5].map((index) => ( + + ))} + + + ); +} + +function KeychainSettingRow( + props: PropsWithChildren<{ + title: string; + icon: IconSetName; + description?: string; + onPress?: () => void; + divider?: boolean; + }>, +): ReactElement { + const tailwind = useTailwind(); + return ( + + + + + {props.title} + + {props.children} + + {props.description ? ( + + {props.description} + + ) : ( + (props.divider ?? true) && ( + + + + ) + )} + + ); +} diff --git a/app/app/keys/settings/passcode.tsx b/app/app/keys/settings/passcode.tsx new file mode 100644 index 0000000..6f2bad8 --- /dev/null +++ b/app/app/keys/settings/passcode.tsx @@ -0,0 +1,63 @@ +import { Stack } from 'expo-router'; +import { ReactElement, useState } from 'react'; +import { Keyboard, SafeAreaView, ScrollView, Text, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { PasscodeInput } from '../../../components/PasscodeInput'; +import { StackHeaderBack } from '../../../components/StackHeader'; + +export default function SettingPasscodePage(): ReactElement { + const tailwind = useTailwind(); + const [validated, setValidated] = useState(false); + + return ( + <> + , + }} + /> + + + + {validated ? ( + + Change Passcode + + ) : ( + + { + setValidated(true); + }} + onError={() => {}} + /> + + )} + + + + ); + + function EnterPasscodeValidation(props: { onValidated: () => void; onError: () => void }): ReactElement { + const [passcode, setPasscode] = useState(''); + + return ( + + Enter Keychain Passcode + + { + setPasscode(text); + if (text.length === 6) { + props.onValidated(); + Keyboard.dismiss(); + } + }} + /> + + ); + } +} diff --git a/app/app/keys/setup/_layout.tsx b/app/app/keys/setup/_layout.tsx new file mode 100644 index 0000000..7b3690c --- /dev/null +++ b/app/app/keys/setup/_layout.tsx @@ -0,0 +1,26 @@ +import { Stack, useRouter } from 'expo-router'; +import { ReactElement } from 'react'; +import { useTailwind } from 'tailwind-rn'; + +import { StackHeaderClose } from '../../../components/StackHeader'; + +export default function KeySetupLayout(): ReactElement { + const tailwind = useTailwind(); + const router = useRouter(); + + return ( + ( + { + router.push('/'); + }} + /> + ), + }} + /> + ); +} diff --git a/app/app/keys/setup/confirm.tsx b/app/app/keys/setup/confirm.tsx new file mode 100644 index 0000000..973c5ad --- /dev/null +++ b/app/app/keys/setup/confirm.tsx @@ -0,0 +1,50 @@ +import { router, Stack } from 'expo-router'; +import { ReactElement, useState } from 'react'; +import { SafeAreaView, ScrollView, Text, TextInput, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { PrimaryActionButton } from '../../../components/Button'; +import { StackHeaderBack } from '../../../components/StackHeader'; + +export default function SetupConfirmPage(): ReactElement { + const tailwind = useTailwind(); + const [sentence, setSentence] = useState(''); + + return ( + <> + , + }} + /> + + + + + Keychain Mnemonic (BIP39) + setSentence(text)} + placeholder="Enter your mnemonic phrase here for verification." + value={sentence} + placeholderTextColor={tailwind('text-zinc-400').color as any} + style={tailwind('px-6 py-2 text-base text-zinc-200 bg-zinc-900 h-48')} + /> + + + + { + await router.push('keys/setup/passcode'); + }} + > + Continue + + + + + + ); +} diff --git a/app/app/keys/setup/generate.tsx b/app/app/keys/setup/generate.tsx new file mode 100644 index 0000000..2657e71 --- /dev/null +++ b/app/app/keys/setup/generate.tsx @@ -0,0 +1,65 @@ +import { NotificationFeedbackType } from 'expo-haptics'; +import { router, Stack } from 'expo-router'; +import { ReactElement, useState } from 'react'; +import { SafeAreaView, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { PrimaryActionButton } from '../../../components/Button'; +import { useHaptic } from '../../HapticFeedback'; +import { IconSet } from '../../IconSet'; + +export default function GeneratePage(): ReactElement { + const tailwind = useTailwind(); + const haptic = useHaptic(); + const [sentence] = useState( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art', + ); + + return ( + <> + + + + + + Keychain Mnemonic (BIP39) + { + await haptic.notificationAsync(NotificationFeedbackType.Success); + }} + > + + + + + + Record your mnemonic phrase and store it securely. It's essential for wallet recovery. This mnemonic + phrase is generated using a cryptographically secure element within your device. In the following step, + you will be asked to verify this mnemonic phrase. + + + + + { + await router.push('keys/setup/confirm'); + }} + > + Continue + + + + + + ); +} diff --git a/app/app/keys/setup/import.tsx b/app/app/keys/setup/import.tsx new file mode 100644 index 0000000..8b182a2 --- /dev/null +++ b/app/app/keys/setup/import.tsx @@ -0,0 +1,53 @@ +import { router, Stack } from 'expo-router'; +import { ReactElement, useState } from 'react'; +import { SafeAreaView, ScrollView, Text, TextInput, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { PrimaryActionButton } from '../../../components/Button'; + +export default function ImportPage(): ReactElement { + const tailwind = useTailwind(); + const [sentence, setSentence] = useState( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art', + ); + + return ( + <> + + + + + Keychain Mnemonic (BIP39) + setSentence(text)} + placeholder="Enter your mnemonic phrase here" + value={sentence} + style={tailwind('px-6 py-2 text-base text-zinc-200 bg-zinc-900 h-48')} + /> + + Import your mnemonic phrase to recover your wallet. Always procure your mnemonic phrase from a reliable + source that ensures a high degree of entropy. Utilizing an untrusted source might lead to theft of your + funds. It's not advisable to choose a mnemonic phrase randomly on your own due to inherent bias. + + + + + { + await router.push('keys/setup/confirm'); + }} + > + Continue + + + + + + ); +} diff --git a/app/app/keys/setup/passcode.tsx b/app/app/keys/setup/passcode.tsx new file mode 100644 index 0000000..5716989 --- /dev/null +++ b/app/app/keys/setup/passcode.tsx @@ -0,0 +1,96 @@ +import { router, Stack } from 'expo-router'; +import { ReactElement, useState } from 'react'; +import { Keyboard, SafeAreaView, ScrollView, Text, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { PrimaryActionButton } from '../../../components/Button'; +import { PasscodeInput } from '../../../components/PasscodeInput'; +import { StackHeaderBack } from '../../../components/StackHeader'; + +export default function SetupPasscodePage(): ReactElement { + const tailwind = useTailwind(); + const [passcode, setPasscode] = useState(''); + const [state, setState] = useState({ + error: '', + correctPasscode: '', + correctCount: 0, + }); + + return ( + <> + , + }} + /> + + + + + + {state.correctCount === 0 ? 'Enter Keychain Passcode' : 'Confirm Keychain Passcode'} + + + {state.error !== '' && ( + {state.error} + )} + + { + if (text.length < 6) { + setPasscode(text); + return; + } + + if (state.correctCount === 0) { + setPasscode(''); + setState({ + error: '', + correctCount: 1, + correctPasscode: text, + }); + return; + } + + if (state.correctPasscode === text) { + Keyboard.dismiss(); + setPasscode(text); + setState({ + error: '', + correctCount: state.correctCount + 1, + correctPasscode: state.correctPasscode, + }); + } else { + setPasscode(''); + setState({ + error: 'Incorrect passcode, please enter again.', + correctCount: 0, + correctPasscode: '', + }); + } + }} + /> + + + Set up a 6-digit passcode to secure your keychain. This passcode will be required when you open the app or + make a transaction. Note that this passcode is different from your device passcode or BIP39 passphrase. + + + + + { + await router.push('keys/setup/safety'); + }} + > + Continue + + + + + + ); +} diff --git a/app/app/keys/setup/safety.tsx b/app/app/keys/setup/safety.tsx new file mode 100644 index 0000000..072f70c --- /dev/null +++ b/app/app/keys/setup/safety.tsx @@ -0,0 +1,152 @@ +import { router, Stack } from 'expo-router'; +import { PropsWithChildren, ReactElement, useState } from 'react'; +import { SafeAreaView, ScrollView, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +import { PrimaryActionButton } from '../../../components/Button'; +import { StackHeaderBack } from '../../../components/StackHeader'; +import { NotificationFeedbackType, useHaptic } from '../../HapticFeedback'; +import { IconSet, IconSetName } from '../../IconSet'; + +export default function SetupSettingsPage(): ReactElement { + const tailwind = useTailwind(); + const haptic = useHaptic(); + const [confirmed, setConfirmed] = useState(Object.fromEntries(acknowledgements.map((_, index) => [index, false]))); + + return ( + <> + , + }} + /> + + + + + + {acknowledgements.map((confirm, index) => ( + + { + await haptic.notificationAsync(NotificationFeedbackType.Success); + setConfirmed({ + ...confirmed, + [index]: !confirmed[index], + }); + }} + /> + + ))} + + + + !value)} + onPress={async () => { + await router.push('/'); + }} + > + Continue + + + + + + ); +} + +const acknowledgements = [ + { + title: 'Multiple Secure Backups', + description: + 'Ensure you have multiple backups located in different safe and secure places. In case one backup is lost or compromised, others will be available.', + }, + { + title: 'Secure Element Entropy', + description: + "The security of your keychain relies on your device's secure element which generates entropy. Your keychain is only as secure as your device.", + }, + { + title: 'Screenshots', + description: + 'Avoid taking screenshots of your mnemonic phrase for security reasons. Bear in mind that any screenshots you take may be uploaded to the cloud. This means that anyone with access to your photos could potentially gain access to your keychain.', + }, + { + title: 'Cloud Storage', + description: + 'Your device might back up your keychain to the cloud. If you have enabled cloud backup, be aware that your keychain could be stored there. This could give actors with access to your cloud storage the ability to access your keychain.', + }, + { + title: 'Password Managers', + description: + "Relying on a password manager entrusts your security to a third party. When you opt for a password manager, you effectively place your keychain's security in their hands.", + }, + { + title: 'Self Custodian', + description: + "You are the sole custodian of your keychain, no one else can assist you in recovering your keychain if it's lost.", + }, +]; + +function KeychainSettingHeader(props: { icon: IconSetName; title: string; description: string }): ReactElement { + const tailwind = useTailwind(); + + return ( + + + + {props.title} + + + {props.description} + + + ); +} + +function KeychainAcknowledgementRow( + props: PropsWithChildren<{ + title: string; + description: string; + value: boolean; + onPress: () => Promise; + }>, +): ReactElement { + const tailwind = useTailwind(); + + return ( + <> + + + + {props.title} + {props.description} + + + + + + + + + ); +} diff --git a/app/app/tabs/index.tsx b/app/app/tabs/index.tsx index 0789961..5a51d19 100644 --- a/app/app/tabs/index.tsx +++ b/app/app/tabs/index.tsx @@ -18,6 +18,7 @@ export default function KeychainTab(): ReactElement { function KeychainOnboarding(): ReactElement { const tailwind = useTailwind(); + const router = useRouter(); const haptic = useHaptic(); return ( @@ -35,19 +36,21 @@ function KeychainOnboarding(): ReactElement { { + router.push('/keys/setup/generate'); await haptic.selectionAsync(); }} icon="calculator" - title="Generate" - caption="A new set mnemonics generated using your device's secure element." + title="Generate Keychain" + caption="Create a new set of mnemonics using your device's secure element." /> { + router.push('/keys/setup/import'); await haptic.selectionAsync(); }} icon="upload" - title="Import" - caption="Import an existing set of mnemonics generated by other means." + title="Import Keychain" + caption="Import a pre-existing set of mnemonics from another secure source." /> diff --git a/app/app/tabs/settings.tsx b/app/app/tabs/settings.tsx index 982bc08..18b7d9a 100644 --- a/app/app/tabs/settings.tsx +++ b/app/app/tabs/settings.tsx @@ -108,7 +108,7 @@ export default function SettingsTab(): ReactElement { }, ]} renderSectionHeader={({ section }) => ( - {section.title} + {section.title} )} renderItem={({ item }) => { switch (item.type) { diff --git a/app/components/Button.tsx b/app/components/Button.tsx index ada33a5..08c06d1 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import { NotificationFeedbackType } from 'expo-haptics'; import { PropsWithChildren, ReactElement } from 'react'; import { Text, TouchableOpacity } from 'react-native'; @@ -9,17 +10,25 @@ import { useHaptic } from '../app/HapticFeedback'; * PrimaryActionButton is a button that is used for primary actions. * They should only be used once per screen, used to confirm an action, or to proceed to the next screen. */ -export function PrimaryActionButton(props: PropsWithChildren<{ onPress: () => void }>): ReactElement { +export function PrimaryActionButton( + props: PropsWithChildren<{ + onPress: () => void; + disabled?: boolean; + }>, +): ReactElement { const tailwind = useTailwind(); const haptic = useHaptic(); return ( { await haptic.notificationAsync(NotificationFeedbackType.Success); props.onPress(); }} - style={tailwind('rounded-full bg-zinc-200 px-8 py-3 w-full')} + style={tailwind( + classNames('rounded-full px-8 py-3 w-full', props.disabled ?? false ? 'bg-zinc-600' : 'bg-zinc-200'), + )} > {props.children} diff --git a/app/components/PasscodeInput.tsx b/app/components/PasscodeInput.tsx new file mode 100644 index 0000000..0202c01 --- /dev/null +++ b/app/components/PasscodeInput.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { ReactElement } from 'react'; +import { TextInput, View } from 'react-native'; +import { useTailwind } from 'tailwind-rn'; + +export function PasscodeInput(props: { value?: string; onValueChange: (passcode: string) => void }): ReactElement { + const tailwind = useTailwind(); + + return ( + + { + if (text.length <= 6) { + props.onValueChange(text); + } + }} + /> + + + ); +} + +function PasscodeBoxes(props: { passcode?: string }): ReactElement { + const tailwind = useTailwind(); + const length = props.passcode?.length ?? 0; + + return ( + + {[1, 2, 3, 4, 5, 6].map((index) => ( + length, + 'bg-zinc-200': index <= length, + }), + )} + /> + ))} + + ); +}