From 812e47862c7aab9baa452a13be8784f56b592284 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:04:57 +0000 Subject: [PATCH] fix(settings): make deleting an account take more than two taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user closed their account from Settings → Account details with two taps, "Delete account" and then "Delete Account", while it held $28 in USDC. A closed account refuses every sign-in, so they could not get back in to reach the money, and support had to reopen the account by hand. The confirmation now asks for more: - The delete button stays disabled until the user types DELETE (case and stray spaces don't matter). Typing a word is the one step that cannot happen by accident. - When the user still holds money, the modal says how much ("You still have at least $28.05 in Solid"), explains that deleting doesn't move it and they won't be able to sign back in to reach it, and suggests withdrawing first. Wallet and savings are read the same way the home screen reads them. The card is left out because its balance means different things per issuer, which is why the figure is "at least". The modal moves to components/Settings/DeleteAccountModal so it can be tested (expo-router would treat a test under app/ as a route), and its balance queries only run while it is open. Whatever was typed is cleared whenever it closes, including after a failed attempt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLdGSDYoyKVTcEQ9rEEF9i --- app/(protected)/(tabs)/settings/account.tsx | 62 +----- components/Settings/DeleteAccountModal.tsx | 190 ++++++++++++++++++ .../__tests__/DeleteAccountModal.test.tsx | 151 ++++++++++++++ 3 files changed, 349 insertions(+), 54 deletions(-) create mode 100644 components/Settings/DeleteAccountModal.tsx create mode 100644 components/Settings/__tests__/DeleteAccountModal.test.tsx 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); + }); +});