Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/popup/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -53,6 +53,7 @@ const App: React.FC = () => {
const [isReady, setIsReady] = useState(false);
const [minTimeElapsed, setMinTimeElapsed] = useState(false);
const [approvalId, setApprovalId] = useState<string | null>(null);
const resetToImport = useRef(false);
const { isInitialized, isLocked, initialize } = useWalletStore();

// Minimum display time for smooth loading animation
Expand Down Expand Up @@ -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 {
Expand All @@ -124,6 +125,11 @@ const App: React.FC = () => {
setView('dashboard');
};

const handleWalletReset = () => {
resetToImport.current = true;
setView('import');
};

const switchToImport = () => {
setView('import');
};
Expand Down Expand Up @@ -202,7 +208,9 @@ const App: React.FC = () => {
{view === 'import' && (
<ImportWallet onSuccess={handleImportSuccess} onSwitchToCreate={switchToCreate} />
)}
{view === 'unlock' && <Unlock onSuccess={handleUnlockSuccess} />}
{view === 'unlock' && (
<Unlock onSuccess={handleUnlockSuccess} onReset={handleWalletReset} />
)}
{view === 'dashboard' && (
<Dashboard
onNavigateToStaking={navigateToStaking}
Expand Down
87 changes: 84 additions & 3 deletions src/popup/pages/Unlock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Box,
Button,
Input,
Expand All @@ -15,14 +21,18 @@ import { useWalletStore } from '@/store/walletStore';

interface Props {
onSuccess: () => void;
onReset: () => void;
}

const Unlock: React.FC<Props> = ({ onSuccess }) => {
const Unlock: React.FC<Props> = ({ 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<HTMLButtonElement>(null);

const { unlock } = useWalletStore();
const { unlock, resetWallet } = useWalletStore();
const toast = useToast();

const handleUnlock = async (e: React.FormEvent) => {
Expand Down Expand Up @@ -58,6 +68,24 @@ const Unlock: React.FC<Props> = ({ 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 (
<Box h="full" bg="#0a0a0a" display="flex" alignItems="center" justifyContent="center" p={6}>
<VStack spacing={8} align="stretch" w="full" maxW="320px">
Expand Down Expand Up @@ -127,8 +155,61 @@ const Unlock: React.FC<Props> = ({ onSuccess }) => {
>
Unlock
</Button>

<Button
type="button"
variant="ghost"
color="gray.500"
_hover={{ color: 'white' }}
size="sm"
onClick={() => setResetOpen(true)}
>
Forgot password?
</Button>
</VStack>
</form>

<AlertDialog
isOpen={resetOpen}
leastDestructiveRef={cancelRef}
onClose={() => setResetOpen(false)}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent bg="#141414" color="white" mx={4}>
<AlertDialogHeader fontSize="lg" fontWeight="semibold">
Reset this wallet?
</AlertDialogHeader>
<AlertDialogBody>
<Text color="gray.300" fontSize="sm">
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.
</Text>
</AlertDialogBody>
<AlertDialogFooter>
<Button
ref={cancelRef}
variant="ghost"
onClick={() => setResetOpen(false)}
isDisabled={resetting}
>
Cancel
</Button>
<Button
bg="red.500"
color="white"
_hover={{ bg: 'red.600' }}
ml={3}
onClick={handleReset}
isLoading={resetting}
>
Reset wallet
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</VStack>
</Box>
);
Expand Down
21 changes: 21 additions & 0 deletions src/store/walletStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ interface WalletState {
importWallet: (mnemonic: string, password: string, accountName?: string) => Promise<void>;
unlock: (password: string) => Promise<void>;
lock: () => Promise<void>;
resetWallet: () => Promise<void>;
selectAccount: (accountId: string) => void;
selectChain: (chainId: string) => void;
setAutoLockMinutes: (minutes: number) => Promise<void>;
Expand Down Expand Up @@ -518,6 +519,26 @@ export const useWalletStore = create<WalletState>((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
Expand Down
65 changes: 65 additions & 0 deletions tests/popup/Unlock.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ChakraProvider>
<Unlock onSuccess={jest.fn()} onReset={onReset} />
</ChakraProvider>
),
};
}

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);
});
});
});
Loading