diff --git a/src/popup/App.tsx b/src/popup/App.tsx index d856a82..8684936 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Box } from '@chakra-ui/react'; import browser from 'webextension-polyfill'; import { useWalletStore } from '@/store/walletStore'; @@ -53,6 +53,7 @@ const App: React.FC = () => { const [isReady, setIsReady] = useState(false); const [minTimeElapsed, setMinTimeElapsed] = useState(false); const [approvalId, setApprovalId] = useState(null); + const resetToImport = useRef(false); const { isInitialized, isLocked, initialize } = useWalletStore(); // Minimum display time for smooth loading animation @@ -104,7 +105,7 @@ const App: React.FC = () => { if (approvalId) return; if (!isInitialized) { - setView('create'); + setView(resetToImport.current ? 'import' : 'create'); } else if (isLocked) { setView('unlock'); } else { @@ -124,6 +125,11 @@ const App: React.FC = () => { setView('dashboard'); }; + const handleWalletReset = () => { + resetToImport.current = true; + setView('import'); + }; + const switchToImport = () => { setView('import'); }; @@ -202,7 +208,9 @@ const App: React.FC = () => { {view === 'import' && ( )} - {view === 'unlock' && } + {view === 'unlock' && ( + + )} {view === 'dashboard' && ( void; + onReset: () => void; } -const Unlock: React.FC = ({ onSuccess }) => { +const Unlock: React.FC = ({ onSuccess, onReset }) => { const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [loading, setLoading] = useState(false); + const [resetOpen, setResetOpen] = useState(false); + const [resetting, setResetting] = useState(false); + const cancelRef = useRef(null); - const { unlock } = useWalletStore(); + const { unlock, resetWallet } = useWalletStore(); const toast = useToast(); const handleUnlock = async (e: React.FormEvent) => { @@ -58,6 +68,24 @@ const Unlock: React.FC = ({ onSuccess }) => { } }; + const handleReset = async () => { + setResetting(true); + try { + await resetWallet(); + setResetOpen(false); + onReset(); + } catch (error) { + toast({ + title: 'Could not reset wallet', + description: error instanceof Error ? error.message : 'Unknown error', + status: 'error', + duration: 4000, + }); + } finally { + setResetting(false); + } + }; + return ( @@ -127,8 +155,61 @@ const Unlock: React.FC = ({ onSuccess }) => { > Unlock + + + + setResetOpen(false)} + isCentered + > + + + + Reset this wallet? + + + + This removes the wallet saved in this browser. Your coins stay on the chain. You + need your recovery phrase to import it and choose a new password. Without that + phrase, this copy cannot be restored. + + + + + + + + + ); diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 645800f..118c2e7 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -141,6 +141,7 @@ interface WalletState { importWallet: (mnemonic: string, password: string, accountName?: string) => Promise; unlock: (password: string) => Promise; lock: () => Promise; + resetWallet: () => Promise; selectAccount: (accountId: string) => void; selectChain: (chainId: string) => void; setAutoLockMinutes: (minutes: number) => Promise; @@ -518,6 +519,26 @@ export const useWalletStore = create((set, get) => ({ }); }, + resetWallet: async () => { + const { keyring } = get(); + + if (keyring) { + keyring.clear(); + } + + await EncryptedStorage.deleteWallet(); + await lockBackground(); + + set({ + isInitialized: false, + isLocked: false, + accounts: [], + selectedAccount: null, + keyring: null, + connectedDapps: new Map(), + }); + }, + selectAccount: (accountId: string) => { const { accounts } = get(); // Find by ID only - no fallback to address diff --git a/tests/popup/Unlock.test.tsx b/tests/popup/Unlock.test.tsx new file mode 100644 index 0000000..48c492d --- /dev/null +++ b/tests/popup/Unlock.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; +import Unlock from '@/popup/pages/Unlock'; + +const unlock = jest.fn(); +const resetWallet = jest.fn(); + +jest.mock('@/store/walletStore', () => ({ + useWalletStore: () => ({ + unlock, + resetWallet, + }), +})); + +function renderUnlock(onReset = jest.fn()) { + return { + onReset, + ...render( + + + + ), + }; +} + +describe('Unlock reset', () => { + beforeEach(() => { + unlock.mockReset(); + resetWallet.mockReset(); + }); + + it('asks before removing the saved wallet', () => { + renderUnlock(); + + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + + expect(screen.getByRole('alertdialog')).toHaveTextContent('Reset this wallet?'); + expect(screen.getByRole('alertdialog')).toHaveTextContent('recovery phrase'); + expect(resetWallet).not.toHaveBeenCalled(); + }); + + it('leaves the wallet in place when reset is cancelled', () => { + renderUnlock(); + + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(resetWallet).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toHaveStyle({ opacity: '0' }); + }); + + it('removes the saved wallet and continues to import', async () => { + resetWallet.mockResolvedValue(undefined); + const { onReset } = renderUnlock(); + + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + fireEvent.click(screen.getByRole('button', { name: 'Reset wallet' })); + + await waitFor(() => { + expect(resetWallet).toHaveBeenCalledTimes(1); + expect(onReset).toHaveBeenCalledTimes(1); + }); + }); +});