mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-06 17:37:51 +08:00
feat(blender): implement advanced blender features and fixes
This commit addresses several issues and implements new features for the Seed Blender based on user feedback. - **Flexible QR Scanning**: `QRScanner` is now content-agnostic. `SeedBlender` detects QR content type (Plain Text, Krux, SeedPGP) and triggers the appropriate workflow. - **Per-Row Decryption**: Replaces the global security panel with a per-row password input for encrypted mnemonics, allowing multiple different encrypted seeds to be used. - **Data Loss Warning**: Implements a confirmation dialog that warns the user if they try to switch tabs with unsaved data in the blender, preventing accidental data loss. - **Final Mnemonic Actions**: Adds 'Transfer to Backup' and 'Export as QR' buttons to the final mnemonic display, allowing the user to utilize the generated seed. - **Refactors `SeedBlender` state management** around a `MnemonicEntry` interface for robustness and clarity.
This commit is contained in:
302
src/App.tsx
302
src/App.tsx
@@ -45,6 +45,7 @@ function App() {
|
||||
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
||||
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
|
||||
|
||||
const [isBlenderDirty, setIsBlenderDirty] = useState(false);
|
||||
|
||||
const [publicKeyInput, setPublicKeyInput] = useState('');
|
||||
const [privateKeyInput, setPrivateKeyInput] = useState('');
|
||||
@@ -350,6 +351,18 @@ function App() {
|
||||
setShowLockConfirm(false);
|
||||
};
|
||||
|
||||
const handleRequestTabChange = (newTab: 'backup' | 'restore' | 'seedblender') => {
|
||||
if (activeTab === 'seedblender' && isBlenderDirty) {
|
||||
if (window.confirm("You have unsaved data in the Seed Blender. Are you sure you want to leave? All progress will be lost.")) {
|
||||
setActiveTab(newTab);
|
||||
setIsBlenderDirty(false); // Reset dirty state on leaving
|
||||
}
|
||||
// else: user cancelled, do nothing
|
||||
} else {
|
||||
setActiveTab(newTab);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-800 text-slate-100">
|
||||
@@ -361,7 +374,7 @@ function App() {
|
||||
events={clipboardEvents}
|
||||
onOpenClipboardModal={() => setShowClipboardModal(true)}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onRequestTabChange={handleRequestTabChange}
|
||||
encryptedMnemonicCache={encryptedMnemonicCache}
|
||||
handleLockAndClear={handleLockAndClear}
|
||||
appVersion={__APP_VERSION__}
|
||||
@@ -478,151 +491,156 @@ function App() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<SeedBlender />
|
||||
<SeedBlender
|
||||
onDirtyStateChange={setIsBlenderDirty}
|
||||
setMnemonicForBackup={setMnemonic}
|
||||
requestTabChange={handleRequestTabChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Panel */}
|
||||
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
|
||||
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Lock size={14} /> SECURITY OPTIONS
|
||||
</label>
|
||||
{/* Security Panel */}
|
||||
{activeTab !== 'seedblender' && (
|
||||
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
|
||||
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Lock size={14} /> SECURITY OPTIONS
|
||||
</label>
|
||||
|
||||
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
|
||||
{/* Removed h3 */}
|
||||
|
||||
{/* Encryption Mode Toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
|
||||
<select
|
||||
value={encryptionMode}
|
||||
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')}
|
||||
disabled={isReadOnly}
|
||||
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
|
||||
>
|
||||
<option value="pgp">PGP (Asymmetric)</option>
|
||||
<option value="krux">Krux KEF (Passphrase)</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{encryptionMode === 'pgp'
|
||||
? 'Uses PGP keys or password'
|
||||
: 'Uses passphrase only (Krux compatible)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
|
||||
{/* Removed h3 */}
|
||||
|
||||
{/* Encryption Mode Toggle */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
|
||||
<select
|
||||
value={encryptionMode}
|
||||
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')}
|
||||
disabled={isReadOnly}
|
||||
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
|
||||
>
|
||||
<option value="pgp">PGP (Asymmetric)</option>
|
||||
<option value="krux">Krux KEF (Passphrase)</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{encryptionMode === 'pgp'
|
||||
? 'Uses PGP keys or password'
|
||||
: 'Uses passphrase only (Krux compatible)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Krux-specific fields */}
|
||||
{encryptionMode === 'krux' && activeTab === 'backup' && (
|
||||
<>
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
|
||||
isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder="e.g., My Seed 2026"
|
||||
value={kruxLabel}
|
||||
onChange={(e) => setKruxLabel(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
|
||||
isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder="e.g., 200000"
|
||||
value={kruxIterations}
|
||||
onChange={(e) => setKruxIterations(Number(e.target.value))}
|
||||
min={10000}
|
||||
step={10000}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||
<input
|
||||
type="password"
|
||||
className={`w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
|
||||
isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
|
||||
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{encryptionMode === 'krux'
|
||||
? 'Required passphrase for Krux encryption'
|
||||
: 'Symmetric encryption password (SKESK)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{activeTab === 'backup' && (
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBip39Passphrase}
|
||||
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
||||
disabled={isReadOnly}
|
||||
className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||
BIP39 25th word active
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{activeTab === 'backup' ? (
|
||||
<button
|
||||
onClick={handleBackup}
|
||||
disabled={!mnemonic || loading || isReadOnly}
|
||||
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<QrCode size={20} />
|
||||
)}
|
||||
{loading ? 'Generating...' : 'Generate QR Backup'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
disabled={!restoreInput || loading || isReadOnly}
|
||||
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<Unlock size={20} />
|
||||
)}
|
||||
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Krux-specific fields */}
|
||||
{encryptionMode === 'krux' && activeTab === 'backup' && (
|
||||
<>
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
|
||||
isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder="e.g., My Seed 2026"
|
||||
value={kruxLabel}
|
||||
onChange={(e) => setKruxLabel(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
|
||||
isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder="e.g., 200000"
|
||||
value={kruxIterations}
|
||||
onChange={(e) => setKruxIterations(Number(e.target.value))}
|
||||
min={10000}
|
||||
step={10000}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||
<input
|
||||
type="password"
|
||||
className={`w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
|
||||
isReadOnly ? 'blur-sm select-none' : ''
|
||||
}`}
|
||||
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
|
||||
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
{encryptionMode === 'krux'
|
||||
? 'Required passphrase for Krux encryption'
|
||||
: 'Symmetric encryption password (SKESK)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{activeTab === 'backup' && (
|
||||
<div className="pt-3 border-t border-slate-300">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBip39Passphrase}
|
||||
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
||||
disabled={isReadOnly}
|
||||
className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||
BIP39 25th word active
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{activeTab === 'backup' ? (
|
||||
<button
|
||||
onClick={handleBackup}
|
||||
disabled={!mnemonic || loading || isReadOnly}
|
||||
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<QrCode size={20} />
|
||||
)}
|
||||
{loading ? 'Generating...' : 'Generate QR Backup'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
disabled={!restoreInput || loading || isReadOnly}
|
||||
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<Unlock size={20} />
|
||||
)}
|
||||
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)} </div>
|
||||
|
||||
{/* QR Output */}
|
||||
{qrPayload && activeTab === 'backup' && (
|
||||
|
||||
@@ -26,7 +26,7 @@ interface HeaderProps {
|
||||
events: ClipboardEvent[];
|
||||
onOpenClipboardModal: () => void;
|
||||
activeTab: 'backup' | 'restore' | 'seedblender';
|
||||
setActiveTab: (tab: 'backup' | 'restore' | 'seedblender') => void;
|
||||
onRequestTabChange: (tab: 'backup' | 'restore' | 'seedblender') => void;
|
||||
encryptedMnemonicCache: any;
|
||||
handleLockAndClear: () => void;
|
||||
appVersion: string;
|
||||
@@ -42,7 +42,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
events,
|
||||
onOpenClipboardModal,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
onRequestTabChange,
|
||||
encryptedMnemonicCache,
|
||||
handleLockAndClear,
|
||||
appVersion,
|
||||
@@ -91,19 +91,19 @@ const Header: React.FC<HeaderProps> = ({
|
||||
)}
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
onClick={() => setActiveTab('backup')}
|
||||
onClick={() => onRequestTabChange('backup')}
|
||||
>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
onClick={() => setActiveTab('restore')}
|
||||
onClick={() => onRequestTabChange('restore')}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg ${activeTab === 'seedblender' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
||||
onClick={() => setActiveTab('seedblender')}
|
||||
onClick={() => onRequestTabChange('seedblender')}
|
||||
>
|
||||
Seed Blender
|
||||
</button>
|
||||
|
||||
@@ -40,12 +40,11 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
||||
aspectRatio: 1.0,
|
||||
},
|
||||
(decodedText) => {
|
||||
if (decodedText.startsWith('SEEDPGP1:')) {
|
||||
// Stop scanning after the first successful detection
|
||||
if (decodedText) {
|
||||
setSuccess(true);
|
||||
onScanSuccess(decodedText);
|
||||
stopCamera();
|
||||
} else {
|
||||
setError('QR code found, but not a valid SEEDPGP1 frame');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -89,13 +88,9 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
||||
// Try scanning with verbose mode
|
||||
const decodedText = await html5QrCode.scanFile(file, true);
|
||||
|
||||
if (decodedText.startsWith('SEEDPGP1:')) {
|
||||
setSuccess(true);
|
||||
onScanSuccess(decodedText);
|
||||
html5QrCode.clear();
|
||||
} else {
|
||||
setError(`Found QR code, but not SEEDPGP format: ${decodedText.substring(0, 30)}...`);
|
||||
}
|
||||
setSuccess(true);
|
||||
onScanSuccess(decodedText);
|
||||
html5QrCode.clear();
|
||||
} catch (err: any) {
|
||||
console.error('File scan error:', err);
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* @file SeedBlender.tsx
|
||||
* @summary Main component for the Seed Blending feature.
|
||||
* @description This component provides a full UI for the multi-step seed blending process,
|
||||
* handling various input formats, per-row decryption, and final output actions.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock } from 'lucide-react';
|
||||
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock, Key, ArrowRight } from 'lucide-react';
|
||||
import QRScanner from './QRScanner';
|
||||
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
|
||||
import { decryptFromKrux } from '../lib/krux';
|
||||
import { QrDisplay } from './QrDisplay';
|
||||
import {
|
||||
blendMnemonicsAsync,
|
||||
checkXorStrength,
|
||||
@@ -18,7 +23,6 @@ import {
|
||||
mixWithDiceAsync,
|
||||
} from '../lib/seedblend';
|
||||
|
||||
// A simple debounce function
|
||||
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<F>): void => {
|
||||
@@ -27,33 +31,53 @@ function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function SeedBlender() {
|
||||
// Step 1 State
|
||||
const [mnemonics, setMnemonics] = useState<string[]>(['']);
|
||||
const [validity, setValidity] = useState<Array<boolean | null>>([null]);
|
||||
interface MnemonicEntry {
|
||||
id: number;
|
||||
rawInput: string;
|
||||
decryptedMnemonic: string | null;
|
||||
isEncrypted: boolean;
|
||||
inputType: 'text' | 'seedpgp' | 'krux';
|
||||
passwordRequired: boolean;
|
||||
passwordInput: string;
|
||||
error: string | null;
|
||||
isValid: boolean | null;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
const createNewEntry = (): MnemonicEntry => ({
|
||||
id: nextId++, rawInput: '', decryptedMnemonic: null, isEncrypted: false,
|
||||
inputType: 'text', passwordRequired: false, passwordInput: '', error: null, isValid: null,
|
||||
});
|
||||
|
||||
interface SeedBlenderProps {
|
||||
onDirtyStateChange: (isDirty: boolean) => void;
|
||||
setMnemonicForBackup: (mnemonic: string) => void;
|
||||
requestTabChange: (tab: 'backup' | 'restore' | 'seedblender') => void;
|
||||
}
|
||||
|
||||
export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange }: SeedBlenderProps) {
|
||||
const [entries, setEntries] = useState<MnemonicEntry[]>([createNewEntry()]);
|
||||
const [showQRScanner, setShowQRScanner] = useState(false);
|
||||
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
|
||||
|
||||
// Step 2 State
|
||||
const [blendedResult, setBlendedResult] = useState<{ blendedEntropy: Uint8Array; blendedMnemonic12: string; blendedMnemonic24?: string; } | null>(null);
|
||||
const [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
|
||||
const [blendError, setBlendError] = useState<string>('');
|
||||
const [blending, setBlending] = useState(false);
|
||||
|
||||
// Step 3 State
|
||||
const [diceRolls, setDiceRolls] = useState('');
|
||||
const [diceStats, setDiceStats] = useState<DiceStats | null>(null);
|
||||
const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null);
|
||||
const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null);
|
||||
|
||||
// Step 4 State
|
||||
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
|
||||
const [mixing, setMixing] = useState(false);
|
||||
const [showFinalQR, setShowFinalQR] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0;
|
||||
onDirtyStateChange(isDirty);
|
||||
}, [entries, diceRolls, onDirtyStateChange]);
|
||||
|
||||
// Main clear function
|
||||
const handleLockAndClear = () => {
|
||||
setMnemonics(['']);
|
||||
setValidity([null]);
|
||||
setEntries([createNewEntry()]);
|
||||
setBlendedResult(null);
|
||||
setXorStrength(null);
|
||||
setBlendError('');
|
||||
@@ -62,108 +86,65 @@ export function SeedBlender() {
|
||||
setDicePatternWarning(null);
|
||||
setDiceOnlyMnemonic(null);
|
||||
setFinalMnemonic(null);
|
||||
}
|
||||
setShowFinalQR(false);
|
||||
};
|
||||
|
||||
// Effect to validate and blend mnemonics (Step 2)
|
||||
useEffect(() => {
|
||||
const processMnemonics = async () => {
|
||||
const processEntries = async () => {
|
||||
setBlending(true);
|
||||
setBlendError('');
|
||||
const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0);
|
||||
|
||||
const filledMnemonics = mnemonics.map(m => m.trim()).filter(m => m.length > 0);
|
||||
if (filledMnemonics.length === 0) {
|
||||
setBlendedResult(null);
|
||||
setXorStrength(null);
|
||||
setValidity(mnemonics.map(() => null));
|
||||
setBlending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValidity: Array<boolean | null> = [...mnemonics].fill(null);
|
||||
const validMnemonics: string[] = [];
|
||||
|
||||
await Promise.all(mnemonics.map(async (mnemonic, index) => {
|
||||
if (mnemonic.trim()) {
|
||||
try {
|
||||
await mnemonicToEntropy(mnemonic.trim());
|
||||
newValidity[index] = true;
|
||||
validMnemonics.push(mnemonic.trim());
|
||||
} catch (e) {
|
||||
newValidity[index] = false;
|
||||
}
|
||||
const validityPromises = entries.map(async (entry) => {
|
||||
if (!entry.rawInput.trim()) return null;
|
||||
if (entry.isEncrypted && !entry.decryptedMnemonic) return null; // Cannot validate until decrypted
|
||||
const textToValidate = entry.decryptedMnemonic || entry.rawInput;
|
||||
try {
|
||||
await mnemonicToEntropy(textToValidate.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
});
|
||||
const newValidity = await Promise.all(validityPromises);
|
||||
setEntries(currentEntries => currentEntries.map((e, i) => ({ ...e, isValid: newValidity[i] })));
|
||||
|
||||
setValidity(newValidity);
|
||||
|
||||
if (validMnemonics.length > 0) {
|
||||
try {
|
||||
const result = await blendMnemonicsAsync(validMnemonics);
|
||||
const strength = checkXorStrength(result.blendedEntropy);
|
||||
setBlendedResult(result);
|
||||
setXorStrength(strength);
|
||||
} catch (e: any) {
|
||||
setBlendError(e.message);
|
||||
setBlendedResult(null);
|
||||
setXorStrength(null);
|
||||
}
|
||||
setXorStrength(checkXorStrength(result.blendedEntropy));
|
||||
} catch (e: any) { setBlendError(e.message); setBlendedResult(null); }
|
||||
} else {
|
||||
setBlendedResult(null);
|
||||
setXorStrength(null);
|
||||
}
|
||||
setBlending(false);
|
||||
};
|
||||
|
||||
debounce(processMnemonics, 300)();
|
||||
}, [mnemonics]);
|
||||
debounce(processEntries, 300)();
|
||||
}, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]);
|
||||
|
||||
// Effect to process dice rolls (Step 3)
|
||||
useEffect(() => {
|
||||
const processDice = async () => {
|
||||
setDiceStats(calculateDiceStats(diceRolls));
|
||||
|
||||
const pattern = detectBadPatterns(diceRolls);
|
||||
setDicePatternWarning(pattern.message || null);
|
||||
|
||||
setDicePatternWarning(detectBadPatterns(diceRolls).message || null);
|
||||
if (diceRolls.length >= 50) {
|
||||
try {
|
||||
const diceBytes = diceToBytes(diceRolls);
|
||||
const outputByteLength = blendedResult?.blendedEntropy.length === 32 ? 32 : 16;
|
||||
const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, new TextEncoder().encode('dice-only'));
|
||||
const mnemonic = await entropyToMnemonic(diceOnlyEntropy);
|
||||
setDiceOnlyMnemonic(mnemonic);
|
||||
} catch (e) {
|
||||
setDiceOnlyMnemonic(null);
|
||||
}
|
||||
} else {
|
||||
setDiceOnlyMnemonic(null);
|
||||
}
|
||||
const outputByteLength = (blendedResult && blendedResult.blendedEntropy.length >= 32) ? 32 : 16;
|
||||
const diceOnlyEntropy = await hkdfExtractExpand(diceToBytes(diceRolls), outputByteLength, new TextEncoder().encode('dice-only'));
|
||||
setDiceOnlyMnemonic(await entropyToMnemonic(diceOnlyEntropy));
|
||||
} catch { setDiceOnlyMnemonic(null); }
|
||||
} else { setDiceOnlyMnemonic(null); }
|
||||
};
|
||||
debounce(processDice, 200)();
|
||||
}, [diceRolls, blendedResult]);
|
||||
|
||||
|
||||
const handleAddMnemonic = () => {
|
||||
setMnemonics([...mnemonics, '']);
|
||||
setValidity([...validity, null]);
|
||||
const updateEntry = (index: number, newProps: Partial<MnemonicEntry>) => {
|
||||
setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry));
|
||||
};
|
||||
|
||||
const handleMnemonicChange = (index: number, value: string) => {
|
||||
const newMnemonics = [...mnemonics];
|
||||
newMnemonics[index] = value;
|
||||
setMnemonics(newMnemonics);
|
||||
};
|
||||
|
||||
const handleRemoveMnemonic = (index: number) => {
|
||||
if (mnemonics.length > 1) {
|
||||
const newMnemonics = mnemonics.filter((_, i) => i !== index);
|
||||
const newValidity = validity.filter((_, i) => i !== index);
|
||||
setMnemonics(newMnemonics);
|
||||
setValidity(newValidity);
|
||||
} else {
|
||||
setMnemonics(['']);
|
||||
setValidity([null]);
|
||||
}
|
||||
const handleAddEntry = () => setEntries([...entries, createNewEntry()]);
|
||||
const handleRemoveEntry = (id: number) => {
|
||||
if (entries.length > 1) setEntries(entries.filter(e => e.id !== id));
|
||||
else setEntries([createNewEntry()]);
|
||||
};
|
||||
|
||||
const handleScan = (index: number) => {
|
||||
@@ -172,243 +153,135 @@ export function SeedBlender() {
|
||||
};
|
||||
|
||||
const handleScanSuccess = (scannedText: string) => {
|
||||
if (scanTargetIndex !== null) {
|
||||
handleMnemonicChange(scanTargetIndex, scannedText);
|
||||
}
|
||||
if (scanTargetIndex === null) return;
|
||||
const mode = detectEncryptionMode(scannedText);
|
||||
const isEncrypted = mode === 'pgp' || mode === 'krux';
|
||||
updateEntry(scanTargetIndex, {
|
||||
rawInput: scannedText, isEncrypted, inputType: isEncrypted ? mode : 'text',
|
||||
passwordRequired: isEncrypted, decryptedMnemonic: isEncrypted ? null : scannedText, error: null,
|
||||
});
|
||||
setShowQRScanner(false);
|
||||
setScanTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDecrypt = async (index: number) => {
|
||||
const entry = entries[index];
|
||||
if (!entry.isEncrypted || !entry.passwordInput) return;
|
||||
try {
|
||||
let mnemonic: string;
|
||||
if (entry.inputType === 'krux') {
|
||||
mnemonic = (await decryptFromKrux({ kefHex: entry.rawInput, passphrase: entry.passwordInput })).mnemonic;
|
||||
} else { // seedpgp
|
||||
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
|
||||
}
|
||||
updateEntry(index, { decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
|
||||
} catch(e: any) {
|
||||
updateEntry(index, { error: e.message || "Decryption failed" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalMix = async () => {
|
||||
if (!blendedResult) return;
|
||||
setMixing(true);
|
||||
try {
|
||||
const outputBits = blendedResult.blendedEntropy.length === 32 ? 256 : 128;
|
||||
const outputBits = blendedResult.blendedEntropy.length >= 32 ? 256 : 128;
|
||||
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
|
||||
setFinalMnemonic(result.finalMnemonic);
|
||||
} catch(e) {
|
||||
// handle error
|
||||
} finally {
|
||||
setMixing(false);
|
||||
}
|
||||
} catch(e) { setFinalMnemonic(null); } finally { setMixing(false); }
|
||||
};
|
||||
|
||||
const handleClearFinal = () => {
|
||||
setFinalMnemonic(null);
|
||||
}
|
||||
const handleTransfer = () => {
|
||||
if (!finalMnemonic) return;
|
||||
setMnemonicForBackup(finalMnemonic);
|
||||
handleLockAndClear();
|
||||
requestTabChange('backup');
|
||||
};
|
||||
|
||||
const getBorderColor = (isValid: boolean | null) => {
|
||||
if (isValid === true) return 'border-green-500 focus:ring-green-500';
|
||||
if (isValid === false) return 'border-red-500 focus:ring-red-500';
|
||||
return 'border-slate-200 focus:ring-teal-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-slate-100">Seed Blender</h2>
|
||||
<button
|
||||
onClick={handleLockAndClear}
|
||||
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
<Lock size={16} />
|
||||
<span>Lock/Clear</span>
|
||||
</button>
|
||||
<button onClick={handleLockAndClear} className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50"><Lock size={16} /><span>Lock/Clear</span></button>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Input Mnemonics */}
|
||||
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
|
||||
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 1: Input Mnemonics</h3>
|
||||
<div className="space-y-4">
|
||||
{mnemonics.map((mnemonic, index) => (
|
||||
<div key={index} className="flex flex-col sm:flex-row items-start gap-2">
|
||||
<div className="relative w-full">
|
||||
<textarea
|
||||
value={mnemonic}
|
||||
onChange={(e) => handleMnemonicChange(index, e.target.value)}
|
||||
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`}
|
||||
className={`w-full h-28 sm:h-24 p-3 pr-10 bg-slate-50 border-2 rounded-lg text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none transition-all resize-none ${getBorderColor(validity[index])}`}
|
||||
data-sensitive={`Mnemonic #${index + 1}`}
|
||||
/>
|
||||
{validity[index] === true && <CheckCircle2 className="absolute top-3 right-3 text-green-500" />}
|
||||
{validity[index] === false && <AlertTriangle className="absolute top-3 right-3 text-red-500" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleScan(index)}
|
||||
className="p-3 h-full bg-purple-600/20 text-purple-300 hover:bg-purple-600/50 hover:text-white rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label="Scan QR Code"
|
||||
>
|
||||
<QrCode size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveMnemonic(index)}
|
||||
className="p-3 h-full bg-red-600/20 text-red-400 hover:bg-red-600/50 hover:text-white rounded-md transition-colors flex items-center justify-center"
|
||||
aria-label="Remove Mnemonic"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleAddMnemonic}
|
||||
className="w-full py-2.5 bg-slate-600/70 hover:bg-slate-600 rounded-lg font-semibold flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Add Another Mnemonic
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Blended Preview */}
|
||||
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600 min-h-[10rem]">
|
||||
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 2: Blended Preview</h3>
|
||||
{blending && <p className="text-sm text-slate-400">Blending...</p>}
|
||||
{blendError && <p className="text-sm text-red-400">{blendError}</p>}
|
||||
|
||||
{!blending && !blendError && blendedResult && (
|
||||
<div className="space-y-4 animate-in fade-in">
|
||||
{xorStrength?.isWeak && (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3">
|
||||
<AlertTriangle />
|
||||
<div>
|
||||
<span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.
|
||||
{entries.map((entry, index) => (
|
||||
<div key={entry.id} className="p-3 bg-slate-800/50 rounded-lg">
|
||||
{entry.passwordRequired ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between"><label className="text-sm font-semibold text-slate-200">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-slate-400 hover:text-white">× Cancel</button></div>
|
||||
<p className="text-xs text-slate-400 truncate">Payload: <code className="text-slate-300">{entry.rawInput.substring(0, 40)}...</code></p>
|
||||
<div className="flex gap-2"><input type="password" placeholder="Enter passphrase to decrypt..." value={entry.passwordInput} onChange={(e) => updateEntry(index, { passwordInput: e.target.value })} className="w-full p-2 bg-slate-50 border-2 border-slate-200 rounded-lg text-sm font-mono text-slate-900 focus:outline-none focus:ring-2 focus:ring-purple-500" /><button onClick={() => handleDecrypt(index)} className="px-4 bg-purple-600 text-white rounded-lg font-semibold hover:bg-purple-700"><Key size={16}/></button></div>
|
||||
{entry.error && <p className="text-xs text-red-400">{entry.error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row items-start gap-2">
|
||||
<div className="relative w-full">
|
||||
<textarea value={entry.rawInput} onChange={(e) => updateEntry(index, { rawInput: e.target.value, decryptedMnemonic: e.target.value, isValid: null })} placeholder={`Mnemonic #${index + 1} (12 or 24 words)`} className={`w-full h-28 sm:h-24 p-3 pr-10 bg-slate-50 border-2 rounded-lg text-sm font-mono text-slate-900 placeholder:text-slate-400 ${getBorderColor(entry.isValid)}`} />
|
||||
{entry.isValid === true && <CheckCircle2 className="absolute top-3 right-3 text-green-500" />}
|
||||
{entry.isValid === false && <AlertTriangle className="absolute top-3 right-3 text-red-500" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => handleScan(index)} className="p-3 h-full bg-purple-600/20 text-purple-300 hover:bg-purple-600/50 hover:text-white rounded-md"><QrCode size={20} /></button>
|
||||
<button onClick={() => handleRemoveEntry(entry.id)} className="p-3 h-full bg-red-600/20 text-red-400 hover:bg-red-600/50 hover:text-white rounded-md"><X size={20} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-400">Blended Mnemonic (12-word)</label>
|
||||
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
|
||||
{blendedResult.blendedMnemonic12}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{blendedResult.blendedMnemonic24 && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label>
|
||||
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
|
||||
{blendedResult.blendedMnemonic24}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!blending && !blendError && !blendedResult && (
|
||||
<p className="text-sm text-slate-400">Previews will appear here once you enter one or more valid mnemonics.</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button onClick={handleAddEntry} className="w-full py-2.5 bg-slate-600/70 hover:bg-slate-600 rounded-lg font-semibold flex items-center justify-center gap-2"><Plus size={16} /> Add Another Mnemonic</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600 min-h-[10rem]">
|
||||
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 2: Blended Preview</h3>
|
||||
{blending ? <p className="text-sm text-slate-400">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (12-word)</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-slate-400">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
|
||||
</div>
|
||||
|
||||
{/* Step 3: Input Dice Rolls */}
|
||||
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
|
||||
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 3: Input Dice Rolls</h3>
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={diceRolls}
|
||||
onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))}
|
||||
placeholder="Enter 99+ dice rolls (e.g., 16345...)"
|
||||
className="w-full h-32 p-3 bg-slate-50 border-2 border-slate-200 rounded-lg text-lg font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none"
|
||||
/>
|
||||
{dicePatternWarning && (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3">
|
||||
<AlertTriangle />
|
||||
<p><span className="font-bold">Warning:</span> {dicePatternWarning}</p>
|
||||
</div>
|
||||
)}
|
||||
{diceStats && diceStats.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div className="p-3 bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-400">Rolls</p>
|
||||
<p className="text-lg font-bold">{diceStats.length}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-400">Entropy (bits)</p>
|
||||
<p className="text-lg font-bold">{diceStats.estimatedEntropyBits.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-400">Mean</p>
|
||||
<p className="text-lg font-bold">{diceStats.mean.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-slate-800 rounded-lg">
|
||||
<p className="text-xs text-slate-400">Chi-Square</p>
|
||||
<p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-amber-400' : ''}`}>{diceStats.chiSquare.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{diceOnlyMnemonic && (
|
||||
<div className="space-y-1 pt-2">
|
||||
<label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label>
|
||||
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
|
||||
{diceOnlyMnemonic}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<textarea value={diceRolls} onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} placeholder="Enter 99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-slate-50 border-2 border-slate-200 rounded-lg text-lg font-mono text-slate-900 placeholder:text-slate-400" />
|
||||
{dicePatternWarning && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
|
||||
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center"><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Rolls</p><p className="text-lg font-bold">{diceStats.length}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Entropy (bits)</p><p className="text-lg font-bold">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Mean</p><p className="text-lg font-bold">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Chi-Square</p><p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-amber-400' : ''}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
|
||||
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{diceOnlyMnemonic}</p></div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Final Mnemonic */}
|
||||
<div className="p-6 bg-slate-900/70 rounded-xl border-2 border-teal-500/50 shadow-lg">
|
||||
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 4: Generate Final Mnemonic</h3>
|
||||
|
||||
{!finalMnemonic ? (
|
||||
<>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final, hardened mnemonic.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleFinalMix}
|
||||
disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing}
|
||||
className="w-full py-3 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{mixing ? (
|
||||
<RefreshCw className="animate-spin" size={20} />
|
||||
) : (
|
||||
<Sparkles size={20} />
|
||||
)}
|
||||
{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg animate-in zoom-in-95">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
|
||||
<CheckCircle2 size={22} /> Final Mnemonic Generated
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearFinal}
|
||||
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
|
||||
>
|
||||
<EyeOff size={22} /> Hide & Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
|
||||
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
|
||||
{finalMnemonic}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-red-500/10 text-red-300 rounded-lg text-xs flex gap-2">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Security Warning:</strong> Write this mnemonic down immediately on paper or metal. Do not save it digitally. Clear when done.
|
||||
</span>
|
||||
{finalMnemonic ? (
|
||||
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4"><span className="font-bold text-green-700 flex items-center gap-2 text-lg"><CheckCircle2 size={22} /> Final Mnemonic Generated</span><button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-green-100 rounded-xl"><EyeOff size={22} /></button></div>
|
||||
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm"><p className="font-mono text-center text-lg text-slate-800 break-words">{finalMnemonic}</p></div>
|
||||
<div className="mt-4 p-3 bg-red-500/10 text-red-300 rounded-lg text-xs flex gap-2"><AlertTriangle size={16} className="shrink-0 mt-0.5" /><span><strong>Security Warning:</strong> Write this down immediately. Do not save it digitally.</span></div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-slate-700 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><QrCode size={16}/> Export as QR</button>
|
||||
<button onClick={handleTransfer} className="w-full py-2.5 bg-teal-600 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><ArrowRight size={16}/> Transfer to Backup</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<><p className="text-sm text-slate-400 mb-4">Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final mnemonic.</p><button onClick={handleFinalMix} disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing} className="w-full py-3 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Scanner Modal */}
|
||||
{showQRScanner && (
|
||||
<QRScanner
|
||||
onScanSuccess={handleScanSuccess}
|
||||
onClose={() => setShowQRScanner(false)}
|
||||
/>
|
||||
{showQRScanner && <QRScanner onScanSuccess={handleScanSuccess} onClose={() => setShowQRScanner(false)} />}
|
||||
{showFinalQR && finalMnemonic && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
|
||||
<div className="bg-white rounded-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<QrDisplay value={finalMnemonic} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user