diff --git a/app/(protected)/(tabs)/settings/account.tsx b/app/(protected)/(tabs)/settings/account.tsx index be0a5b6d9..dc9aa4b2a 100644 --- a/app/(protected)/(tabs)/settings/account.tsx +++ b/app/(protected)/(tabs)/settings/account.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { ActivityIndicator, Alert, Modal, Pressable, Text, View } from 'react-native'; +import { Alert, Pressable, Text, View } from 'react-native'; import { Image } from 'expo-image'; -import { ChevronRight, X } from 'lucide-react-native'; +import { ChevronRight } from 'lucide-react-native'; import { Address } from 'viem'; import WalletIcon from '@/assets/images/wallet'; @@ -9,6 +9,7 @@ import CopyToClipboard from '@/components/CopyToClipboard'; import Navbar from '@/components/Navbar'; import PageLayout from '@/components/PageLayout'; import { SettingsCard } from '@/components/Settings'; +import DeleteAccountModal from '@/components/Settings/DeleteAccountModal'; import { BackButton } from '@/components/ui/back-button'; import { useDimension } from '@/hooks/useDimension'; import useUser from '@/hooks/useUser'; @@ -62,59 +63,12 @@ export default function Account() { ); const deleteModal = ( - !isDeleting && setShowDeleteModal(false)} - > - - - - Delete Account - !isDeleting && setShowDeleteModal(false)}> - - - - - - Are you sure you want to delete your account? This action cannot be undone and will: - - - - • Remove all your data - • Cancel any active cards - • Delete your transaction history - • Remove access to your wallet - - - - setShowDeleteModal(false)} - className="mr-2 flex-1 rounded-xl bg-gray-700 py-4" - disabled={isDeleting} - > - Cancel - - - - {isDeleting ? ( - - ) : ( - Delete Account - )} - - - - - + isDeleting={isDeleting} + onCancel={() => setShowDeleteModal(false)} + onConfirm={confirmDelete} + /> ); return ( diff --git a/components/Settings/DeleteAccountModal.tsx b/components/Settings/DeleteAccountModal.tsx new file mode 100644 index 000000000..cabea860a --- /dev/null +++ b/components/Settings/DeleteAccountModal.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + Text, + View, +} from 'react-native'; +import { AlertTriangle, X } from 'lucide-react-native'; + +import Input from '@/components/ui/input'; +import { useTotalSavingsUSD } from '@/hooks/useTotalSavingsUSD'; +import { useWalletTokens } from '@/hooks/useWalletTokens'; +import { cn, formatBalanceUSD } from '@/lib/utils'; + +/** + * What the user has to type before "Delete Account" does anything. + * + * Two taps used to be enough, and a user closed an account holding $28 that way + * and then could not sign back in to reach it. Typing a word is the one step + * that cannot happen by accident. + */ +export const DELETE_CONFIRMATION_WORD = 'DELETE'; + +/** Whether what was typed confirms the deletion. Case and stray spaces don't count against it. */ +export const isDeleteConfirmation = (typed: string): boolean => + typed.trim().toUpperCase() === DELETE_CONFIRMATION_WORD; + +/** + * Tells the user what they still hold before they delete the account. + * + * Deleting does not move any money: it stays in their wallet, and a closed + * account cannot sign in to reach it. Wallet and savings are the pots that + * matter for that and are read the same way the home screen reads them; the + * card is left out because its balance means different things per issuer, so + * the figure is "at least" rather than the headline. + * + * Only mounted while the modal is open, so the balance queries don't run for + * everyone who merely opens Account details. + */ +export function HeldBalanceWarning() { + const { totalUSDExcludingVaultTokens: walletUsd } = useWalletTokens(); + const { data: savingsUsd } = useTotalSavingsUSD(); + + const heldUsd = (walletUsd || 0) + (savingsUsd || 0); + if (heldUsd < 0.01) return null; + + return ( + + + + + You still have at least {formatBalanceUSD(heldUsd)} in Solid + + + Deleting your account doesn't move this money, and you won't be able to sign + back in to reach it. Withdraw it or send it to another wallet first. + + + + ); +} + +type DeleteAccountModalProps = { + visible: boolean; + isDeleting: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +/** + * The last step before an account is closed. + * + * Closing locks the user out — every sign-in is refused afterwards, and only + * support can reopen the account — so this asks for more than a tap: it shows + * what the user still holds, and the delete button stays disabled until they + * type {@link DELETE_CONFIRMATION_WORD}. + */ +export default function DeleteAccountModal({ + visible, + isDeleting, + onCancel, + onConfirm, +}: DeleteAccountModalProps) { + const [confirmationText, setConfirmationText] = useState(''); + const isConfirmed = isDeleteConfirmation(confirmationText); + + // Whatever was typed must not carry over to the next opening — including after a failed + // attempt, which closes the modal from the parent rather than through `cancel`. + useEffect(() => { + if (!visible) setConfirmationText(''); + }, [visible]); + + const cancel = () => { + if (isDeleting) return; + onCancel(); + }; + + const confirm = () => { + if (!isConfirmed || isDeleting) return; + onConfirm(); + }; + + return ( + + + + + + Delete Account + + + + + + {visible && } + + + Are you sure you want to delete your account? This action cannot be undone and will: + + + + • Remove all your data + • Cancel any active cards + • Delete your transaction history + • Remove access to your wallet + + + + Type {DELETE_CONFIRMATION_WORD} to + confirm + + + + + + Cancel + + + + {isDeleting ? ( + + ) : ( + + Delete Account + + )} + + + + + + + ); +} diff --git a/components/Settings/__tests__/DeleteAccountModal.test.tsx b/components/Settings/__tests__/DeleteAccountModal.test.tsx new file mode 100644 index 000000000..38b6babd1 --- /dev/null +++ b/components/Settings/__tests__/DeleteAccountModal.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; + +import DeleteAccountModal, { isDeleteConfirmation } from '@/components/Settings/DeleteAccountModal'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +let mockWalletUsd = 0; +let mockSavingsUsd: number | undefined = 0; + +jest.mock('@/hooks/useWalletTokens', () => ({ + useWalletTokens: () => ({ totalUSDExcludingVaultTokens: mockWalletUsd }), +})); +jest.mock('@/hooks/useTotalSavingsUSD', () => ({ + useTotalSavingsUSD: () => ({ data: mockSavingsUsd }), +})); +jest.mock('@/lib/utils', () => ({ + cn: (...inputs: unknown[]) => inputs.filter(Boolean).join(' '), + formatBalanceUSD: (value: number) => `$${value.toFixed(2)}`, +})); +jest.mock('@/components/ui/input', () => 'Input'); +jest.mock('lucide-react-native', () => ({ AlertTriangle: 'AlertTriangle', X: 'X' })); + +const render = (props: Partial> = {}) => { + const onConfirm = jest.fn(); + const onCancel = jest.fn(); + let tree: any; + act(() => { + tree = create( + , + ); + }); + return { tree, onConfirm, onCancel }; +}; + +/** The "Delete Account" button — the pressable that owns the destructive action. */ +const deleteButton = (tree: any) => + tree.root.find( + (node: any) => node.props.accessibilityLabel === 'Delete Account' && node.props.onPress, + ); + +const typeConfirmation = (tree: any, text: string) => { + act(() => { + tree.root.findByType('Input').props.onChangeText(text); + }); +}; + +const allText = (tree: any): string => + tree.root + .findAll((node: any) => typeof node.children?.[0] === 'string') + .map((node: any) => node.children.join('')) + .join(' '); + +afterEach(() => { + mockWalletUsd = 0; + mockSavingsUsd = 0; +}); + +/** + * A user closed an account holding $28 with two taps, then could not sign back + * in to reach it — a closed account refuses every sign-in. Deleting now takes a + * typed word, and says what the user still holds before they do it. + */ +describe('DeleteAccountModal', () => { + test('does not delete until the confirmation word is typed', () => { + const { tree, onConfirm } = render(); + + expect(deleteButton(tree).props.disabled).toBe(true); + act(() => deleteButton(tree).props.onPress()); + expect(onConfirm).not.toHaveBeenCalled(); + + typeConfirmation(tree, 'DELET'); + expect(deleteButton(tree).props.disabled).toBe(true); + + act(() => tree.unmount()); + }); + + test('deletes once the word is typed, whatever the case', () => { + const { tree, onConfirm } = render(); + + typeConfirmation(tree, ' delete '); + expect(deleteButton(tree).props.disabled).toBe(false); + + act(() => deleteButton(tree).props.onPress()); + expect(onConfirm).toHaveBeenCalledTimes(1); + + act(() => tree.unmount()); + }); + + test('warns what the user still holds before they delete', () => { + mockWalletUsd = 28.01; + mockSavingsUsd = 0.04; + const { tree } = render(); + + expect(allText(tree)).toContain('You still have at least $28.05 in Solid'); + + act(() => tree.unmount()); + }); + + test('says nothing about money to a user who holds none', () => { + mockSavingsUsd = undefined; + const { tree } = render(); + + expect(allText(tree)).not.toContain('You still have'); + + act(() => tree.unmount()); + }); + + test('forgets what was typed once it closes', () => { + const { tree, onConfirm, onCancel } = render(); + typeConfirmation(tree, 'DELETE'); + + act(() => { + tree.update( + , + ); + }); + act(() => { + tree.update( + , + ); + }); + + expect(deleteButton(tree).props.disabled).toBe(true); + + act(() => tree.unmount()); + }); +}); + +describe('isDeleteConfirmation', () => { + test.each([ + ['DELETE', true], + ['delete', true], + [' Delete ', true], + ['DELETED', false], + ['', false], + ])('%p -> %p', (typed, expected) => { + expect(isDeleteConfirmation(typed)).toBe(expected); + }); +});