mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +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' && (
|
||||
|
||||
Reference in New Issue
Block a user