mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(ui): restore cross-provider RespondQueuePanel and purge legacy Mnemonic page (#2000)
This commit is contained in:
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import AddAccountModal from '../components/accounts/AddAccountModal';
|
||||
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
|
||||
// import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
|
||||
import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
|
||||
import WebviewHost from '../components/accounts/WebviewHost';
|
||||
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
@@ -109,11 +109,11 @@ const Accounts = () => {
|
||||
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
|
||||
// const { snapshot } = useCoreState();
|
||||
// const welcomeLocked = isWelcomeLocked(snapshot);
|
||||
// Respond-queue selectors disabled while RespondQueuePanel is hidden.
|
||||
// const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
|
||||
// const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
|
||||
// const respondQueueStatus = useAppSelector(state => state.providerSurfaces.status);
|
||||
// const respondQueueError = useAppSelector(state => state.providerSurfaces.error);
|
||||
// Respond-queue selectors enabled
|
||||
const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
|
||||
const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
|
||||
const respondQueueStatus = useAppSelector(state => state.providerSurfaces.status);
|
||||
const respondQueueError = useAppSelector(state => state.providerSurfaces.error);
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
|
||||
@@ -281,9 +281,8 @@ const Accounts = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<AgentChatPanel />
|
||||
</div>
|
||||
{/* Respond queue side panel hidden for now — bring back when
|
||||
the cross-provider surface is ready to ship. */}
|
||||
{/* <RespondQueuePanel
|
||||
{/* Respond queue side panel restored */}
|
||||
<RespondQueuePanel
|
||||
items={respondQueue}
|
||||
count={respondQueueCount}
|
||||
status={respondQueueStatus}
|
||||
@@ -291,7 +290,7 @@ const Accounts = () => {
|
||||
onRefresh={() => {
|
||||
void dispatch(fetchRespondQueue());
|
||||
}}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
) : active ? (
|
||||
<div className="flex-1 py-3 pr-3">
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import LottieAnimation from '../components/LottieAnimation';
|
||||
import { persistLocalWalletFromMnemonic } from '../features/wallet/setupLocalWalletFromMnemonic';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import {
|
||||
generateMnemonicPhrase,
|
||||
MNEMONIC_GENERATE_WORD_COUNT,
|
||||
validateMnemonicPhrase,
|
||||
} from '../utils/cryptoKeys';
|
||||
|
||||
/** Allowed BIP39 phrase lengths for import (includes legacy 24-word backups). */
|
||||
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
|
||||
|
||||
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
|
||||
|
||||
const Mnemonic = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot, setEncryptionKey } = useCoreState();
|
||||
const user = snapshot.currentUser;
|
||||
const [mode, setMode] = useState<'generate' | 'import'>('generate');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Generate mode state
|
||||
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
|
||||
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
|
||||
|
||||
// Import mode state
|
||||
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
const [importValid, setImportValid] = useState<boolean | null>(null);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timer = setTimeout(() => setCopied(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
const switchMode = useCallback((nextMode: 'generate' | 'import') => {
|
||||
setMode(nextMode);
|
||||
setConfirmed(false);
|
||||
setError(null);
|
||||
setImportValid(null);
|
||||
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(mnemonic);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = mnemonic;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
}
|
||||
}, [mnemonic]);
|
||||
|
||||
const handleImportWordChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (pastedWords.length > 1) {
|
||||
const fullPhraseLen = pastedWords.length;
|
||||
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setImportWords(pastedWords.map(w => w.toLowerCase()));
|
||||
setImportValid(null);
|
||||
inputRefs.current[fullPhraseLen - 1]?.focus();
|
||||
return;
|
||||
}
|
||||
const newWords = [...importWords];
|
||||
const slotCount = newWords.length;
|
||||
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
|
||||
newWords[index + i] = pastedWords[i].toLowerCase();
|
||||
}
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
const nextEmpty = newWords.findIndex(w => !w);
|
||||
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const newWords = [...importWords];
|
||||
newWords[index] = value.toLowerCase().trim();
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
|
||||
if (value.trim() && index < newWords.length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
},
|
||||
[importWords]
|
||||
);
|
||||
|
||||
const handleImportKeyDown = useCallback(
|
||||
(index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Backspace' && !importWords[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
},
|
||||
[importWords]
|
||||
);
|
||||
|
||||
const handleValidateImport = useCallback(() => {
|
||||
const phrase = importWords.join(' ').trim();
|
||||
const filledWords = importWords.filter(w => w.trim());
|
||||
const n = filledWords.length;
|
||||
|
||||
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
|
||||
setImportValid(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isValid = validateMnemonicPhrase(phrase);
|
||||
setImportValid(isValid);
|
||||
|
||||
if (!isValid) {
|
||||
setError('Invalid recovery phrase. Please check your words and try again.');
|
||||
return false;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
return true;
|
||||
}, [importWords]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let phraseToUse: string;
|
||||
|
||||
if (mode === 'import') {
|
||||
if (!handleValidateImport()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
phraseToUse = importWords.join(' ').trim();
|
||||
} else {
|
||||
if (!confirmed) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
phraseToUse = mnemonic;
|
||||
}
|
||||
|
||||
if (!user?._id) {
|
||||
const msg = 'User not loaded. Please sign in again or refresh the page.';
|
||||
setError(msg);
|
||||
console.error('[Mnemonic] Cannot save encryption key: user not loaded');
|
||||
return;
|
||||
}
|
||||
await persistLocalWalletFromMnemonic({
|
||||
mnemonic: phraseToUse,
|
||||
source: mode === 'generate' ? 'generated' : 'imported',
|
||||
setEncryptionKey,
|
||||
});
|
||||
navigate('/home');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importWordCount = importWords.filter(w => w.trim()).length;
|
||||
const isImportComplete =
|
||||
importWords.every(w => w.trim()) &&
|
||||
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
|
||||
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center p-4 pt-6">
|
||||
<div className="max-w-lg w-full">
|
||||
<div className="flex justify-center mb-6">
|
||||
<LottieAnimation src="/lottie/safe3.json" height={120} width={120} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-8 animate-fade-up">
|
||||
{mode === 'generate' ? (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
|
||||
somewhere safe. This phrase unlocks your local encryption key and your EVM, BTC,
|
||||
Solana, and Tron wallet identities, and can never be recovered if lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mnemonic Grid */}
|
||||
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{words.map((word, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 bg-white/60 rounded-lg px-3 py-2 text-sm">
|
||||
<span className="text-stone-400 font-mono text-xs w-5 text-right">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="font-mono font-medium text-stone-800">{word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full flex items-center justify-center gap-2 border border-stone-200 hover:bg-stone-50 text-stone-700 font-medium py-2.5 text-sm rounded-xl transition-all duration-200 mb-3">
|
||||
{copied ? (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4 text-sage-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sage-600">Copied to Clipboard</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Copy to Clipboard</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Import existing link */}
|
||||
<button
|
||||
onClick={() => switchMode('import')}
|
||||
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
|
||||
{t('mnemonic.alreadyHavePhrase')}
|
||||
</button>
|
||||
|
||||
{/* Confirmation Checkbox */}
|
||||
<label className="flex items-start gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={e => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded border-stone-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-stone-600">
|
||||
I saved this phrase and consent to using it for local wallet setup on this device
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">{t('mnemonic.copyWarning')}</p>
|
||||
</div>
|
||||
|
||||
{/* Import Word Inputs Grid */}
|
||||
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{importWords.map((word, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<span className="text-stone-400 font-mono text-xs w-5 text-right shrink-0">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<input
|
||||
ref={el => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
value={word}
|
||||
onChange={e => handleImportWordChange(index, e.target.value)}
|
||||
onKeyDown={e => handleImportKeyDown(index, e)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white/60 outline-none transition-colors ${
|
||||
importValid === false && word.trim()
|
||||
? 'border-coral-300 focus:border-coral-400'
|
||||
: importValid === true
|
||||
? 'border-sage-300 focus:border-sage-400'
|
||||
: 'border-stone-200 focus:border-primary-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation status */}
|
||||
{importValid === true && (
|
||||
<div className="flex items-center gap-2 text-sage-600 text-sm mb-3 justify-center">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Valid recovery phrase</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to generate link */}
|
||||
<button
|
||||
onClick={() => switchMode('generate')}
|
||||
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
|
||||
{t('mnemonic.generateNewPhrase')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-coral-500 text-sm mb-3 text-center">{error}</p>}
|
||||
|
||||
{/* Continue Button */}
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!canContinue || loading}
|
||||
className="w-full flex items-center justify-center space-x-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold py-2.5 text-sm rounded-xl transition-all duration-300 hover:shadow-medium">
|
||||
<span>
|
||||
{loading
|
||||
? 'Securing Your Data...'
|
||||
: mode === 'import'
|
||||
? 'Import & Continue'
|
||||
: "I'm Ready! Let's Go!"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mnemonic;
|
||||
@@ -1,656 +0,0 @@
|
||||
/// <reference types="@testing-library/jest-dom/vitest" />
|
||||
/**
|
||||
* Tests for the Mnemonic page.
|
||||
*
|
||||
* Coverage areas:
|
||||
* - Initial render: generate mode UI, word grid, buttons
|
||||
* - Copy to clipboard (success + fallback paths)
|
||||
* - Confirmation checkbox gates the Continue button
|
||||
* - Mode switch: generate ↔ import, state resets on switch
|
||||
* - Import mode: word input, auto-advance, backspace navigation, paste
|
||||
* - Validation: incomplete phrase, invalid phrase, valid phrase
|
||||
* - handleContinue — generate mode: happy path, no user, crypto error
|
||||
* - handleContinue — import mode: happy path, validation failure, no user
|
||||
* - Loading state during continue
|
||||
* - Navigation to /home on success
|
||||
* - Core-state setEncryptionKey persistence
|
||||
*/
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Mnemonic from '../src/pages/Mnemonic';
|
||||
import { renderWithProviders } from '../src/test/test-utils';
|
||||
import type { User } from '../src/types/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FIXED_MNEMONIC =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ' +
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
|
||||
|
||||
const {
|
||||
mockGenerateMnemonicPhrase,
|
||||
mockValidateMnemonicPhrase,
|
||||
mockDeriveAesKey,
|
||||
mockSetEncryptionKey,
|
||||
mockEncryptSecret,
|
||||
mockUseCoreState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGenerateMnemonicPhrase: vi.fn(() => FIXED_MNEMONIC),
|
||||
mockValidateMnemonicPhrase: vi.fn(() => true),
|
||||
mockDeriveAesKey: vi.fn(() => 'aes-key-hex'),
|
||||
mockSetEncryptionKey: vi.fn().mockResolvedValue(undefined),
|
||||
mockEncryptSecret: vi.fn().mockResolvedValue({ result: 'enc2:wallet-secret' }),
|
||||
mockUseCoreState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/utils/cryptoKeys', () => ({
|
||||
MNEMONIC_GENERATE_WORD_COUNT: 24,
|
||||
generateMnemonicPhrase: mockGenerateMnemonicPhrase,
|
||||
validateMnemonicPhrase: mockValidateMnemonicPhrase,
|
||||
deriveAesKeyFromMnemonic: mockDeriveAesKey,
|
||||
}));
|
||||
|
||||
vi.mock('../src/providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
|
||||
vi.mock('../src/utils/tauriCommands/auth', () => ({
|
||||
openhumanEncryptSecret: (...args: unknown[]) => mockEncryptSecret(...args),
|
||||
}));
|
||||
|
||||
// LottieAnimation makes network calls; stub it out
|
||||
vi.mock('../src/components/LottieAnimation', () => ({
|
||||
default: () => <div data-testid="lottie" />,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORD_COUNT = 24;
|
||||
const FIXED_WORDS = FIXED_MNEMONIC.split(' '); // 24 words
|
||||
|
||||
/** User with a valid _id so the "user not loaded" guard passes. */
|
||||
const mockUser: Partial<User> = { _id: 'user-123', username: 'tester' };
|
||||
|
||||
/** Render with a user already in the store. */
|
||||
const renderWithUser = () => renderWithProviders(<Mnemonic />);
|
||||
|
||||
/** Render without a user in the store (unauthenticated). */
|
||||
const renderWithoutUser = () => renderWithProviders(<Mnemonic />);
|
||||
|
||||
/** Switch to import mode. */
|
||||
const switchToImport = () => fireEvent.click(screen.getByText('I already have a recovery phrase'));
|
||||
|
||||
/** Fill all 24 import inputs with the words from `phrase`. */
|
||||
const fillAllImportWords = (phrase = FIXED_MNEMONIC) => {
|
||||
const words = phrase.split(' ');
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
// Paste into the first field to trigger multi-word paste handling
|
||||
fireEvent.change(inputs[0], { target: { value: words.join(' ') } });
|
||||
};
|
||||
|
||||
/** Get the Continue button. */
|
||||
const continueButton = () => screen.getByRole('button', { name: /import & continue|let's go/i });
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerateMnemonicPhrase.mockClear();
|
||||
mockValidateMnemonicPhrase.mockClear();
|
||||
mockDeriveAesKey.mockClear();
|
||||
mockSetEncryptionKey.mockClear();
|
||||
mockEncryptSecret.mockClear();
|
||||
mockUseCoreState.mockReturnValue({
|
||||
snapshot: { currentUser: mockUser, sessionToken: 'jwt-token' },
|
||||
setEncryptionKey: mockSetEncryptionKey,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate mode — initial render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — generate mode: initial render', () => {
|
||||
it('renders the "Your Recovery Phrase" heading', () => {
|
||||
renderWithUser();
|
||||
expect(screen.getByText('Your Recovery Phrase')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders all 24 words from the generated mnemonic', () => {
|
||||
renderWithUser();
|
||||
for (const word of FIXED_WORDS) {
|
||||
expect(screen.getAllByText(word).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders 24 numbered word tiles', () => {
|
||||
renderWithUser();
|
||||
expect(screen.getByText('1.')).toBeTruthy();
|
||||
expect(screen.getByText('24.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the Copy to Clipboard button', () => {
|
||||
renderWithUser();
|
||||
expect(screen.getByRole('button', { name: /copy to clipboard/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the amber warning notice', () => {
|
||||
renderWithUser();
|
||||
expect(screen.getByText(/can never be recovered if lost/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the confirmation checkbox unchecked', () => {
|
||||
renderWithUser();
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the Continue button disabled before confirmation', () => {
|
||||
renderWithUser();
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the "I already have a recovery phrase" link', () => {
|
||||
renderWithUser();
|
||||
expect(screen.getByText('I already have a recovery phrase')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate mode — confirmation checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — generate mode: confirmation checkbox', () => {
|
||||
it('enables the Continue button when checkbox is checked', () => {
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables the Continue button again when checkbox is unchecked', () => {
|
||||
renderWithUser();
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(checkbox);
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate mode — copy to clipboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — generate mode: copy to clipboard', () => {
|
||||
it('calls navigator.clipboard.writeText with the full mnemonic', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
renderWithUser();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(FIXED_MNEMONIC);
|
||||
});
|
||||
|
||||
it('shows "Copied to Clipboard" after clicking copy', async () => {
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
||||
renderWithUser();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Copied to Clipboard')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('resets "Copied" text back to "Copy to Clipboard" after 3 s', async () => {
|
||||
vi.useFakeTimers();
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
||||
renderWithUser();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
|
||||
});
|
||||
|
||||
// Flush the resolved clipboard promise so setCopied(true) fires
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// Now the 3-second reset timer has also been run
|
||||
expect(screen.queryByText('Copied to Clipboard')).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses execCommand fallback when clipboard API throws', async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('blocked')) },
|
||||
});
|
||||
// jsdom does not implement execCommand — define it so we can spy
|
||||
if (!document.execCommand) {
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
value: vi.fn().mockReturnValue(true),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true);
|
||||
|
||||
renderWithUser();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Copied to Clipboard')).toBeTruthy());
|
||||
expect(execCommand).toHaveBeenCalledWith('copy');
|
||||
execCommand.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode switching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — mode switching', () => {
|
||||
it('switches to import mode on "I already have a recovery phrase" click', () => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
expect(screen.getByText('Import Recovery Phrase')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows 24 text inputs in import mode', () => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(WORD_COUNT);
|
||||
});
|
||||
|
||||
it('switches back to generate mode on "Generate a new recovery phrase instead"', () => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
|
||||
expect(screen.getByText('Your Recovery Phrase')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets error when switching modes', async () => {
|
||||
renderWithUser();
|
||||
// Trigger an error in generate mode (click continue without confirming)
|
||||
fireEvent.click(continueButton()); // disabled, won't trigger, so force via import mode
|
||||
// Switch to import mode and back — confirmed state should reset
|
||||
switchToImport();
|
||||
expect(screen.queryByText(/please enter all/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('resets confirmation when switching from generate to import and back', () => {
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
switchToImport();
|
||||
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
|
||||
|
||||
// Confirmed state is reset — Continue should be disabled again
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import mode — word input behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — import mode: word input', () => {
|
||||
beforeEach(() => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
});
|
||||
|
||||
it('updates input value when a word is typed', () => {
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
fireEvent.change(inputs[0], { target: { value: 'abandon' } });
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('abandon');
|
||||
});
|
||||
|
||||
it('lowercases the entered word', () => {
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
fireEvent.change(inputs[0], { target: { value: 'ABANDON' } });
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('abandon');
|
||||
});
|
||||
|
||||
it('Continue button stays disabled until all 24 words are filled', () => {
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
// Fill only 23 words
|
||||
for (let i = 0; i < 23; i++) {
|
||||
fireEvent.change(inputs[i], { target: { value: 'abandon' } });
|
||||
}
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('Continue button becomes enabled when all 24 words are filled (via paste)', () => {
|
||||
fillAllImportWords();
|
||||
expect((continueButton() as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('distributes pasted multi-word phrase across inputs starting at index 0', () => {
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
fireEvent.change(inputs[0], { target: { value: FIXED_WORDS.join(' ') } });
|
||||
|
||||
// After paste the first input gets the first word
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe(FIXED_WORDS[0]);
|
||||
});
|
||||
|
||||
it('distributes pasted phrase starting from a non-zero index', () => {
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
const remaining = FIXED_WORDS.slice(1).join(' ');
|
||||
fireEvent.change(inputs[1], { target: { value: remaining } });
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe(FIXED_WORDS[1]);
|
||||
expect((inputs[2] as HTMLInputElement).value).toBe(FIXED_WORDS[2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import mode — keyboard navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — import mode: keyboard navigation', () => {
|
||||
beforeEach(() => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
});
|
||||
|
||||
it('does not move focus backward on Backspace when input has text', () => {
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
fireEvent.change(inputs[1], { target: { value: 'test' } });
|
||||
inputs[1].focus();
|
||||
fireEvent.keyDown(inputs[1], { key: 'Backspace' });
|
||||
// focus should stay on inputs[1]
|
||||
expect(document.activeElement).toBe(inputs[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import mode — validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — import mode: validation', () => {
|
||||
beforeEach(() => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
});
|
||||
|
||||
it('shows error when the phrase fails BIP39 validation after all 24 words are entered', async () => {
|
||||
// The Continue button is only enabled when all 24 inputs are filled, so the
|
||||
// "please enter all words" branch is unreachable via normal UI.
|
||||
// The reachable validation error is the invalid-phrase message.
|
||||
mockValidateMnemonicPhrase.mockReturnValueOnce(false);
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeTruthy());
|
||||
});
|
||||
|
||||
it('shows error when the 24-word phrase is invalid (BIP39)', async () => {
|
||||
mockValidateMnemonicPhrase.mockReturnValueOnce(false);
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeTruthy());
|
||||
});
|
||||
|
||||
it('shows "Valid recovery phrase" text when phrase passes BIP39 validation', async () => {
|
||||
mockValidateMnemonicPhrase.mockReturnValue(true);
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Valid recovery phrase')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleContinue — generate mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — handleContinue: generate mode', () => {
|
||||
it('shows loading text while processing', async () => {
|
||||
let resolveEncryptionKey!: () => void;
|
||||
mockSetEncryptionKey.mockReturnValueOnce(
|
||||
new Promise<void>(res => {
|
||||
resolveEncryptionKey = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeTruthy());
|
||||
|
||||
await act(async () => {
|
||||
resolveEncryptionKey();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setEncryptionKey with the derived AES key', async () => {
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
expect(mockSetEncryptionKey).toHaveBeenCalledWith('aes-key-hex');
|
||||
});
|
||||
|
||||
it('calls deriveAesKeyFromMnemonic with the generated mnemonic', async () => {
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
|
||||
});
|
||||
|
||||
it('shows "User not loaded" error when user._id is missing', async () => {
|
||||
mockUseCoreState.mockReturnValue({
|
||||
snapshot: { currentUser: null, sessionToken: 'jwt-token' },
|
||||
setEncryptionKey: mockSetEncryptionKey,
|
||||
});
|
||||
renderWithoutUser();
|
||||
|
||||
// The checkbox click + continue in generate mode with no user
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText(/user not loaded/i).length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('shows an error message when deriveAesKeyFromMnemonic throws', async () => {
|
||||
mockDeriveAesKey.mockImplementationOnce(() => {
|
||||
throw new Error('crypto failure');
|
||||
});
|
||||
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('crypto failure')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('does not navigate when unconfirmed in generate mode', async () => {
|
||||
renderWithUser();
|
||||
// Do NOT check the checkbox
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
// No dispatch should have happened
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(mockSetEncryptionKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleContinue — import mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — handleContinue: import mode', () => {
|
||||
beforeEach(() => {
|
||||
mockValidateMnemonicPhrase.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('derives the encryption key from the imported phrase and navigates to /home', async () => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
|
||||
});
|
||||
|
||||
it('calls setEncryptionKey on successful import', async () => {
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
expect(mockSetEncryptionKey).toHaveBeenCalledWith('aes-key-hex');
|
||||
});
|
||||
|
||||
it('does not call deriveAesKey when validation fails', async () => {
|
||||
mockValidateMnemonicPhrase.mockReturnValueOnce(false);
|
||||
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeTruthy());
|
||||
expect(mockDeriveAesKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows "User not loaded" error when user is absent during import', async () => {
|
||||
mockValidateMnemonicPhrase.mockReturnValue(true);
|
||||
|
||||
mockUseCoreState.mockReturnValue({
|
||||
snapshot: { currentUser: null, sessionToken: 'jwt-token' },
|
||||
setEncryptionKey: mockSetEncryptionKey,
|
||||
});
|
||||
renderWithProviders(<Mnemonic />);
|
||||
switchToImport();
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/user not loaded/i)).toBeTruthy());
|
||||
});
|
||||
|
||||
it('shows an error when setEncryptionKey throws during import', async () => {
|
||||
mockSetEncryptionKey.mockRejectedValueOnce(new Error('encryption error'));
|
||||
|
||||
renderWithUser();
|
||||
switchToImport();
|
||||
fillAllImportWords();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('encryption error')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading state during continue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — loading state', () => {
|
||||
it('disables Continue button while loading', async () => {
|
||||
let resolveEncryptionKey!: () => void;
|
||||
mockSetEncryptionKey.mockReturnValueOnce(
|
||||
new Promise<void>(res => {
|
||||
resolveEncryptionKey = res;
|
||||
})
|
||||
);
|
||||
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
const btn = continueButton();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeTruthy());
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveEncryptionKey();
|
||||
});
|
||||
});
|
||||
|
||||
it('restores button label after an error', async () => {
|
||||
mockDeriveAesKey.mockImplementationOnce(() => {
|
||||
throw new Error('oops');
|
||||
});
|
||||
|
||||
renderWithUser();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(continueButton());
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('oops')).toBeTruthy());
|
||||
expect(screen.queryByText('Securing Your Data...')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider configuration sanity checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Mnemonic — providerConfigs sanity', () => {
|
||||
it('calls generateMnemonicPhrase exactly once on mount', () => {
|
||||
mockGenerateMnemonicPhrase.mockClear();
|
||||
renderWithUser();
|
||||
// useMemo with [] dep runs once per render
|
||||
expect(mockGenerateMnemonicPhrase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call generateMnemonicPhrase again after mode switch', () => {
|
||||
mockGenerateMnemonicPhrase.mockClear();
|
||||
renderWithUser();
|
||||
const callsBefore = mockGenerateMnemonicPhrase.mock.calls.length;
|
||||
switchToImport();
|
||||
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
|
||||
expect(mockGenerateMnemonicPhrase.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user