mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-06 17:37:51 +08:00
1483 lines
71 KiB
TypeScript
1483 lines
71 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { QrCode, RefreshCw, CheckCircle2, Lock, AlertCircle, Camera, Dices, Mic, Unlock, EyeOff, FileKey, Info, Package } from 'lucide-react';
|
|
import { PgpKeyInput } from './components/PgpKeyInput';
|
|
import { QrDisplay } from './components/QrDisplay';
|
|
import QRScanner from './components/QRScanner';
|
|
import { validateBip39Mnemonic } from './lib/bip39';
|
|
import { buildPlaintext, encryptToSeed, decryptFromSeed, detectEncryptionMode, validatePGPKey } from './lib/seedpgp';
|
|
import { encodeStandardSeedQR, encodeCompactSeedQREntropy } from './lib/seedqr';
|
|
import { SecurityWarnings } from './components/SecurityWarnings';
|
|
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
|
|
import { EncryptionMode, EncryptionResult } from './lib/types'; // Import EncryptionMode and EncryptionResult
|
|
import Header from './components/Header';
|
|
import { StorageDetails } from './components/StorageDetails';
|
|
import { ClipboardDetails } from './components/ClipboardDetails';
|
|
import Footer from './components/Footer';
|
|
import { SeedBlender } from './components/SeedBlender';
|
|
import CameraEntropy from './components/CameraEntropy';
|
|
import DiceEntropy from './components/DiceEntropy';
|
|
import RandomOrgEntropy from './components/RandomOrgEntropy';
|
|
import { InteractionEntropy } from './lib/interactionEntropy';
|
|
import { TestRecovery } from './components/TestRecovery';
|
|
import { generateRecoveryKit } from './lib/recoveryKit';
|
|
|
|
import AudioEntropy from './AudioEntropy';
|
|
|
|
interface StorageItem {
|
|
key: string;
|
|
value: string;
|
|
size: number;
|
|
isSensitive: boolean;
|
|
}
|
|
|
|
interface ClipboardEvent {
|
|
timestamp: Date;
|
|
field: string;
|
|
length: number;
|
|
}
|
|
|
|
function App() {
|
|
const [activeTab, setActiveTab] = useState<'create' | 'backup' | 'restore' | 'seedblender' | 'test-recovery'>('create');
|
|
const [mnemonic, setMnemonic] = useState('');
|
|
const [backupMessagePassword, setBackupMessagePassword] = useState('');
|
|
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
|
|
|
|
const [publicKeyInput, setPublicKeyInput] = useState('');
|
|
const [privateKeyInput, setPrivateKeyInput] = useState('');
|
|
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
|
|
const [hasBip39Passphrase, setHasBip39Passphrase] = useState(false);
|
|
const [qrPayload, setQrPayload] = useState<string | Uint8Array>('');
|
|
const [recipientFpr, setRecipientFpr] = useState('');
|
|
const [restoreInput, setRestoreInput] = useState('');
|
|
const [decryptedRestoredMnemonic, setDecryptedRestoredMnemonic] = useState<string | null>(null);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [showQRScanner, setShowQRScanner] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isReadOnly, setIsReadOnly] = useState(false);
|
|
const [_encryptedMnemonicCache, _setEncryptedMnemonicCache] = useState<EncryptedBlob | null>(null);
|
|
const [showSecurityModal, setShowSecurityModal] = useState(false);
|
|
const [showStorageModal, setShowStorageModal] = useState(false);
|
|
const [showClipboardModal, setShowClipboardModal] = useState(false);
|
|
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
|
|
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
|
|
const [clipboardEvents, setClipboardEvents] = useState<ClipboardEvent[]>([]);
|
|
const [showLockConfirm, setShowLockConfirm] = useState(false);
|
|
const [resetCounter, setResetCounter] = useState(0);
|
|
|
|
// Krux integration state
|
|
const [encryptionMode, setEncryptionMode] = useState<'pgp' | 'krux' | 'seedqr'>('pgp');
|
|
|
|
const [seedQrFormat, setSeedQrFormat] = useState<'standard' | 'compact'>('standard');
|
|
const [generatedSeed, setGeneratedSeed] = useState<string>('');
|
|
const [seedWordCount, setSeedWordCount] = useState<12 | 24>(24);
|
|
const [seedDestination, setSeedDestination] = useState<'backup' | 'seedblender'>('backup');
|
|
const [detectedMode, setDetectedMode] = useState<EncryptionMode | null>(null);
|
|
const [seedForBlender, setSeedForBlender] = useState<string>('');
|
|
const [blenderResetKey, setBlenderResetKey] = useState(0);
|
|
|
|
// Network blocking state
|
|
const [isNetworkBlocked, setIsNetworkBlocked] = useState(true);
|
|
|
|
// Entropy generation states
|
|
const [entropySource, setEntropySource] = useState<'camera' | 'dice' | 'audio' | 'randomorg' | null>(null);
|
|
const [entropyStats, setEntropyStats] = useState<any>(null);
|
|
const interactionEntropyRef = useRef(new InteractionEntropy());
|
|
|
|
|
|
|
|
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
|
|
|
|
const isSensitiveKey = (key: string): boolean => {
|
|
const lowerKey = key.toLowerCase();
|
|
return SENSITIVE_PATTERNS.some(pattern => lowerKey.includes(pattern));
|
|
};
|
|
|
|
const getStorageItems = (storage: Storage): StorageItem[] => {
|
|
const items: StorageItem[] = [];
|
|
for (let i = 0; i < storage.length; i++) {
|
|
const key = storage.key(i);
|
|
if (key) {
|
|
const value = storage.getItem(key) || '';
|
|
items.push({
|
|
key,
|
|
value: value.substring(0, 50) + (value.length > 50 ? '...' : ''),
|
|
size: new Blob([value]).size,
|
|
isSensitive: isSensitiveKey(key)
|
|
});
|
|
}
|
|
}
|
|
return items.sort((a, b) => (b.isSensitive ? 1 : 0) - (a.isSensitive ? 1 : 0));
|
|
};
|
|
|
|
const refreshStorage = () => {
|
|
setLocalItems(getStorageItems(localStorage));
|
|
setSessionItems(getStorageItems(sessionStorage));
|
|
};
|
|
|
|
useEffect(() => {
|
|
refreshStorage();
|
|
const interval = setInterval(refreshStorage, 2000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
blockAllNetworks();
|
|
// setIsNetworkBlocked(true); // already set by default state
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
blockAllNetworks();
|
|
// setIsNetworkBlocked(true); // already set by default state
|
|
}, []);
|
|
|
|
|
|
|
|
// Cleanup session key on component unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
destroySessionKey();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleCopy = (e: ClipboardEvent & Event) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Get selection to measure length
|
|
const selection = window.getSelection()?.toString() || '';
|
|
const length = selection.length;
|
|
|
|
if (length === 0) return; // Nothing copied
|
|
|
|
// Detect field name
|
|
let field = 'Unknown field';
|
|
|
|
// Check for data-sensitive attribute on any element first
|
|
const sensitiveAttr = target.getAttribute('data-sensitive') ||
|
|
target.closest('[data-sensitive]')?.getAttribute('data-sensitive');
|
|
if (sensitiveAttr) {
|
|
field = sensitiveAttr;
|
|
} else if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') {
|
|
// Try multiple ways to identify the field for legacy inputs
|
|
field =
|
|
target.getAttribute('aria-label') ||
|
|
target.getAttribute('name') ||
|
|
target.getAttribute('id') ||
|
|
(target as HTMLInputElement).type ||
|
|
target.tagName.toLowerCase();
|
|
|
|
// Check parent labels
|
|
const label = target.closest('label') ||
|
|
document.querySelector(`label[for="${target.id}"]`);
|
|
if (label) {
|
|
field = label.textContent?.trim() || field;
|
|
}
|
|
|
|
// Detect if it looks like sensitive data
|
|
const isSensitive = /mnemonic|seed|key|private|password|secret/i.test(
|
|
target.className + ' ' + field + ' ' + (target.getAttribute('placeholder') || '')
|
|
);
|
|
|
|
if (isSensitive && field === target.tagName.toLowerCase()) {
|
|
// Try to guess from placeholder
|
|
const placeholder = target.getAttribute('placeholder');
|
|
if (placeholder) {
|
|
field = placeholder.substring(0, 40) + '...';
|
|
}
|
|
}
|
|
}
|
|
|
|
setClipboardEvents(prev => [
|
|
{ timestamp: new Date(), field, length },
|
|
...prev.slice(0, 9) // Keep last 10 events
|
|
]);
|
|
};
|
|
|
|
document.addEventListener('copy', handleCopy as EventListener);
|
|
return () => document.removeEventListener('copy', handleCopy as EventListener);
|
|
}, []);
|
|
|
|
// Detect encryption mode from restore input
|
|
useEffect(() => {
|
|
if (activeTab === 'restore' && restoreInput.trim()) {
|
|
const detected = detectEncryptionMode(restoreInput);
|
|
setDetectedMode(detected);
|
|
// Auto-switch encryption mode if not already set AND it's an encrypted type
|
|
if ((detected === 'pgp' || detected === 'krux') && detected !== encryptionMode) {
|
|
setEncryptionMode(detected);
|
|
}
|
|
} else {
|
|
setDetectedMode(null);
|
|
}
|
|
}, [restoreInput, activeTab, encryptionMode]);
|
|
|
|
const clearClipboard = async () => {
|
|
try {
|
|
// Actually clear the system clipboard
|
|
await navigator.clipboard.writeText('');
|
|
|
|
// Clear history
|
|
setClipboardEvents([]);
|
|
|
|
// Show success briefly
|
|
alert('✅ Clipboard cleared and history wiped');
|
|
} catch (err) {
|
|
// Fallback for browsers that don't support clipboard API
|
|
const dummy = document.createElement('textarea');
|
|
dummy.value = '';
|
|
document.body.appendChild(dummy);
|
|
dummy.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(dummy);
|
|
|
|
setClipboardEvents([]);
|
|
alert('✅ History cleared (clipboard may require manual clearing)');
|
|
}
|
|
};
|
|
|
|
|
|
const copyToClipboard = async (text: string | Uint8Array, fieldName = 'Data') => {
|
|
if (isReadOnly) {
|
|
setError("Copy to clipboard is disabled in Read-only mode.");
|
|
return;
|
|
}
|
|
|
|
const textToCopy = typeof text === 'string' ? text : Array.from(text).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(textToCopy);
|
|
setCopied(true);
|
|
|
|
// Add warning for sensitive data
|
|
const isSensitive = fieldName.toLowerCase().includes('mnemonic') ||
|
|
fieldName.toLowerCase().includes('seed') ||
|
|
fieldName.toLowerCase().includes('password') ||
|
|
fieldName.toLowerCase().includes('key');
|
|
|
|
if (isSensitive) {
|
|
setClipboardEvents(prev => [
|
|
{
|
|
timestamp: new Date(),
|
|
field: `${fieldName} (will clear in 10s)`,
|
|
length: textToCopy.length
|
|
},
|
|
...prev.slice(0, 9)
|
|
]);
|
|
|
|
// Auto-clear clipboard after 10 seconds by writing random data
|
|
setTimeout(async () => {
|
|
try {
|
|
const garbage = crypto.getRandomValues(new Uint8Array(Math.max(textToCopy.length, 64)))
|
|
.reduce((s, b) => s + String.fromCharCode(32 + (b % 95)), '');
|
|
await navigator.clipboard.writeText(garbage);
|
|
} catch { }
|
|
}, 10000);
|
|
|
|
// Show warning
|
|
alert(`⚠️ ${fieldName} copied to clipboard!\n\n✅ Will auto-clear in 10 seconds.\n\n🔒 Warning: Clipboard is accessible to other apps and browser extensions.`);
|
|
}
|
|
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
} catch {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = textToCopy;
|
|
ta.style.position = "fixed";
|
|
ta.style.left = "-9999px";
|
|
document.body.appendChild(ta);
|
|
ta.focus();
|
|
ta.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
}
|
|
};
|
|
|
|
// Handler for entropy generation
|
|
const handleEntropyGenerated = (mnemonic: string, stats: any) => {
|
|
setGeneratedSeed(mnemonic);
|
|
setEntropyStats(stats);
|
|
};
|
|
|
|
// Handler for sending to destination
|
|
const handleSendToDestination = () => {
|
|
if (seedDestination === 'backup') {
|
|
setMnemonic(generatedSeed);
|
|
setActiveTab('backup');
|
|
} else if (seedDestination === 'seedblender') {
|
|
setSeedForBlender(generatedSeed);
|
|
setActiveTab('seedblender');
|
|
}
|
|
|
|
// Reset Create tab
|
|
setGeneratedSeed('');
|
|
setEntropySource(null);
|
|
setEntropyStats(null);
|
|
};
|
|
|
|
const handleBackup = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
setQrPayload('');
|
|
setRecipientFpr('');
|
|
|
|
try {
|
|
const validation = await validateBip39Mnemonic(mnemonic);
|
|
if (!validation.valid) {
|
|
throw new Error(validation.error);
|
|
}
|
|
|
|
const plaintext = buildPlaintext(mnemonic, hasBip39Passphrase);
|
|
|
|
let result: EncryptionResult;
|
|
if (encryptionMode === 'seedqr') {
|
|
if (seedQrFormat === 'standard') {
|
|
const qrString = await encodeStandardSeedQR(mnemonic);
|
|
result = { framed: qrString };
|
|
} else { // compact
|
|
const qrEntropy = await encodeCompactSeedQREntropy(mnemonic);
|
|
result = { framed: qrEntropy }; // framed will hold the Uint8Array
|
|
}
|
|
} else {
|
|
// Validate PGP public key before encryption
|
|
if (publicKeyInput) {
|
|
const validation = await validatePGPKey(publicKeyInput);
|
|
if (!validation.valid) {
|
|
throw new Error(`PGP Key Validation Failed: ${validation.error}`);
|
|
}
|
|
}
|
|
|
|
// Encrypt with PGP or Krux
|
|
result = await encryptToSeed({
|
|
plaintext,
|
|
publicKeyArmored: publicKeyInput || undefined,
|
|
messagePassword: backupMessagePassword || undefined,
|
|
mode: encryptionMode,
|
|
});
|
|
}
|
|
|
|
setQrPayload(result.framed);
|
|
if (result.recipientFingerprint) {
|
|
setRecipientFpr(result.recipientFingerprint);
|
|
}
|
|
|
|
// Initialize session key before encrypting
|
|
await getSessionKey();
|
|
// Encrypt mnemonic with session key and clear plaintext state
|
|
const blob = await encryptJsonToBlob({ mnemonic, timestamp: Date.now() });
|
|
_setEncryptedMnemonicCache(blob);
|
|
setMnemonic(''); // Clear plaintext mnemonic
|
|
|
|
// Clear password after successful encryption (security best practice)
|
|
setBackupMessagePassword('');
|
|
setPrivateKeyPassphrase(''); // Also clear PGP passphrase if used
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Encryption failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
const handleFileUpload = async (file: File) => {
|
|
setLoading(true);
|
|
setError('');
|
|
|
|
try {
|
|
// Handle image files (QR codes)
|
|
if (file.type.startsWith('image/')) {
|
|
const img = new Image();
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve;
|
|
img.onerror = reject;
|
|
img.src = URL.createObjectURL(file);
|
|
});
|
|
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
ctx?.drawImage(img, 0, 0);
|
|
|
|
const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height);
|
|
if (!imageData) throw new Error('Could not read image');
|
|
|
|
const jsqr = (await import('jsqr')).default;
|
|
const code = jsqr(imageData.data, imageData.width, imageData.height);
|
|
|
|
if (!code) throw new Error('No QR code found in image');
|
|
|
|
// Handle binary or text QR data
|
|
const isBinary = (code.binaryData.length === 16 || code.binaryData.length === 32);
|
|
if (isBinary) {
|
|
const hex = Array.from(code.binaryData).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
setRestoreInput(hex);
|
|
} else {
|
|
setRestoreInput(code.data);
|
|
}
|
|
|
|
URL.revokeObjectURL(img.src);
|
|
}
|
|
// Handle text files (PGP armor, hex, numeric SeedQR)
|
|
else if (file.type === 'text/plain' || file.name.endsWith('.txt') || file.name.endsWith('.asc')) {
|
|
const text = await file.text();
|
|
setRestoreInput(text.trim());
|
|
}
|
|
else {
|
|
throw new Error('Unsupported file type. Use PNG/JPG (QR) or TXT/ASC (armor)');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'File upload failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDrop = async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
|
|
const file = e.dataTransfer.files[0]; // Access the first file
|
|
if (file) await handleFileUpload(file);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const handleRestore = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
setDecryptedRestoredMnemonic(null);
|
|
|
|
try {
|
|
// Auto-detect mode if not manually set
|
|
const modeToUse = detectedMode || encryptionMode;
|
|
|
|
const result = await decryptFromSeed({
|
|
frameText: restoreInput,
|
|
privateKeyArmored: privateKeyInput || undefined,
|
|
privateKeyPassphrase: privateKeyPassphrase || undefined,
|
|
messagePassword: restoreMessagePassword || undefined,
|
|
mode: modeToUse,
|
|
});
|
|
|
|
// Encrypt the restored mnemonic with the session key
|
|
await getSessionKey();
|
|
const blob = await encryptJsonToBlob({ mnemonic: result.w, timestamp: Date.now() });
|
|
_setEncryptedMnemonicCache(blob);
|
|
|
|
// Temporarily display the mnemonic and then clear it
|
|
setDecryptedRestoredMnemonic(result.w);
|
|
|
|
// Clear passwords after successful decryption (security best practice)
|
|
setRestoreMessagePassword('');
|
|
setPrivateKeyPassphrase('');
|
|
// Also clear restore input after successful decrypt
|
|
setRestoreInput('');
|
|
setTimeout(() => {
|
|
setDecryptedRestoredMnemonic(null);
|
|
}, 10000); // Auto-clear after 10 seconds
|
|
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Decryption failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const blockAllNetworks = () => {
|
|
// Store originals
|
|
(window as any).__originalFetch = window.fetch;
|
|
(window as any).__originalXHR = window.XMLHttpRequest;
|
|
(window as any).__originalWS = window.WebSocket;
|
|
(window as any).__originalImage = window.Image;
|
|
if ((navigator as any).sendBeacon) {
|
|
(window as any).__originalBeacon = navigator.sendBeacon;
|
|
}
|
|
|
|
// 1. Block fetch
|
|
window.fetch = (async () =>
|
|
Promise.reject(new Error('Network blocked by user'))
|
|
) as any;
|
|
|
|
// 2. Block XMLHttpRequest
|
|
window.XMLHttpRequest = new Proxy(XMLHttpRequest, {
|
|
construct() {
|
|
throw new Error('Network blocked: XMLHttpRequest not allowed');
|
|
}
|
|
}) as any;
|
|
|
|
// 3. Block WebSocket
|
|
window.WebSocket = new Proxy(WebSocket, {
|
|
construct() {
|
|
throw new Error('Network blocked: WebSocket not allowed');
|
|
}
|
|
}) as any;
|
|
|
|
// 4. Block BeaconAPI
|
|
(navigator as any).sendBeacon = () => {
|
|
return false;
|
|
};
|
|
|
|
// 5. Block Image src for external resources
|
|
const OriginalImage = window.Image;
|
|
window.Image = new Proxy(OriginalImage, {
|
|
construct(target) {
|
|
const img = Reflect.construct(target, []);
|
|
const originalSrcSetter = Object.getOwnPropertyDescriptor(
|
|
HTMLImageElement.prototype, 'src'
|
|
)?.set;
|
|
|
|
Object.defineProperty(img, 'src', {
|
|
configurable: true,
|
|
set(value) {
|
|
if (value && !value.startsWith('data:') && !value.startsWith('blob:')) {
|
|
throw new Error(`Network blocked: cannot load external resource`);
|
|
}
|
|
originalSrcSetter?.call(this, value);
|
|
},
|
|
get: Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')?.get
|
|
});
|
|
|
|
return img;
|
|
}
|
|
}) as any;
|
|
|
|
// 6. Block Service Workers
|
|
if (navigator.serviceWorker) {
|
|
// Block new registrations
|
|
(navigator.serviceWorker as any).register = async () => {
|
|
throw new Error('Network blocked: Service Workers disabled');
|
|
};
|
|
|
|
// Unregister any existing service workers (defense-in-depth)
|
|
navigator.serviceWorker
|
|
.getRegistrations()
|
|
.then(regs => regs.forEach(reg => reg.unregister()))
|
|
.catch(() => {
|
|
// Ignore errors; SWs are defense-in-depth only.
|
|
});
|
|
}
|
|
};
|
|
|
|
const unblockAllNetworks = () => {
|
|
// Restore everything
|
|
if ((window as any).__originalFetch) window.fetch = (window as any).__originalFetch;
|
|
if ((window as any).__originalXHR) window.XMLHttpRequest = (window as any).__originalXHR;
|
|
if ((window as any).__originalWS) window.WebSocket = (window as any).__originalWS;
|
|
if ((window as any).__originalImage) window.Image = (window as any).__originalImage;
|
|
if ((window as any).__originalBeacon) navigator.sendBeacon = (window as any).__originalBeacon;
|
|
};
|
|
|
|
const handleToggleNetwork = () => {
|
|
setIsNetworkBlocked(!isNetworkBlocked);
|
|
|
|
if (!isNetworkBlocked) {
|
|
blockAllNetworks();
|
|
} else {
|
|
unblockAllNetworks();
|
|
}
|
|
};
|
|
|
|
const handleRequestTabChange = (newTab: 'create' | 'backup' | 'restore' | 'seedblender' | 'test-recovery') => {
|
|
// Allow free navigation - no warnings
|
|
// User can manually reset Seed Blender with "Reset All" button
|
|
setActiveTab(newTab);
|
|
};
|
|
|
|
const handleResetAll = () => {
|
|
if (window.confirm('⚠️ Reset ALL data? This will clear everything including any displayed entropy analysis.')) {
|
|
// Clear component state
|
|
setMnemonic('');
|
|
setGeneratedSeed('');
|
|
setBackupMessagePassword('');
|
|
setRestoreMessagePassword('');
|
|
setPublicKeyInput('');
|
|
setPrivateKeyInput('');
|
|
setPrivateKeyPassphrase('');
|
|
setQrPayload('');
|
|
setRecipientFpr('');
|
|
setRestoreInput('');
|
|
setDecryptedRestoredMnemonic(null);
|
|
setError('');
|
|
setEntropySource(null);
|
|
setEntropyStats(null);
|
|
setSeedForBlender('');
|
|
|
|
// Clear storage and session
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
destroySessionKey();
|
|
_setEncryptedMnemonicCache(null);
|
|
|
|
// Force remount of key-driven components
|
|
setResetCounter(prev => prev + 1);
|
|
setBlenderResetKey(prev => prev + 1);
|
|
|
|
// Go to Create tab (fresh start)
|
|
setActiveTab('create');
|
|
}
|
|
};
|
|
|
|
const handleRestoreScanSuccess = useCallback((scannedData: string | Uint8Array) => {
|
|
if (typeof scannedData === 'string') {
|
|
setRestoreInput(scannedData);
|
|
} else {
|
|
const hex = Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
setRestoreInput(hex);
|
|
}
|
|
setShowQRScanner(false);
|
|
setError('');
|
|
}, []); // Empty dependency array means this function is stable
|
|
|
|
const handleRestoreClose = useCallback(() => {
|
|
setShowQRScanner(false);
|
|
}, []);
|
|
|
|
const handleRestoreError = useCallback((err: string) => {
|
|
setError(err);
|
|
setShowQRScanner(false);
|
|
}, []);
|
|
|
|
// Handle download recovery kit
|
|
const handleDownloadRecoveryKit = async () => {
|
|
if (!qrPayload) {
|
|
setError('No backup available to export');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError('');
|
|
|
|
// Get QR image as data URL from canvas
|
|
const qrCanvas = document.querySelector('canvas');
|
|
const qrImageDataUrl = qrCanvas?.toDataURL('image/png');
|
|
|
|
// Determine encryption method
|
|
const encryptionMethod = publicKeyInput && backupMessagePassword ? 'both'
|
|
: publicKeyInput ? 'publickey'
|
|
: 'password';
|
|
|
|
const kitBlob = await generateRecoveryKit({
|
|
encryptedData: qrPayload,
|
|
encryptionMode: encryptionMode,
|
|
encryptionMethod: encryptionMethod,
|
|
fingerprint: recipientFpr,
|
|
qrImageDataUrl,
|
|
});
|
|
|
|
// Trigger download
|
|
const url = URL.createObjectURL(kitBlob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `seedpgp-recovery-kit-${new Date().toISOString().split('T')[0]}.zip`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
alert('✅ Recovery kit downloaded! Store this ZIP file safely.');
|
|
} catch (err: any) {
|
|
setError(`Failed to generate recovery kit: ${err.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
<div className="min-h-screen bg-black">
|
|
<div className="max-w-md mx-auto min-h-screen bg-[#0a0a0f] bg-[radial-gradient(ellipse_at_top,#1a1a2e_0%,#0a0a0f_100%)] relative">
|
|
{/* Cyberpunk grid overlay */}
|
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,240,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,240,255,0.03)_1px,transparent_1px)] bg-[size:50px_50px] pointer-events-none" />
|
|
|
|
{/* Content wrapper */}
|
|
<div className="relative z-10">
|
|
|
|
<Header
|
|
onOpenSecurityModal={() => setShowSecurityModal(true)}
|
|
localItems={localItems}
|
|
sessionItems={sessionItems}
|
|
onOpenStorageModal={() => setShowStorageModal(true)}
|
|
events={clipboardEvents}
|
|
onOpenClipboardModal={() => setShowClipboardModal(true)}
|
|
activeTab={activeTab}
|
|
onRequestTabChange={handleRequestTabChange}
|
|
appVersion={__APP_VERSION__}
|
|
isNetworkBlocked={isNetworkBlocked}
|
|
onToggleNetwork={handleToggleNetwork}
|
|
onResetAll={handleResetAll}
|
|
/>
|
|
<main className="w-full px-4 py-3">
|
|
<div className="bg-[#1a1a2e] rounded-xl border-2 border-[#00f0ff]/30 shadow-[0_0_30px_rgba(0,240,255,0.3)] p-0">
|
|
<div className="p-6 md:p-8 space-y-6">
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div
|
|
className="p-4 bg-[#1a1a2e] border-2 border-[#ff006e] rounded-lg text-[#ff006e] text-sm shadow-[0_0_20px_rgba(255,0,110,0.3)] flex gap-3 items-start animate-in slide-in-from-top-2"
|
|
style={{ textShadow: '0 0 5px rgba(255,0,110,0.5)' }}
|
|
>
|
|
<AlertCircle className="shrink-0 mt-0.5" size={20} />
|
|
<div>
|
|
<p className="font-bold mb-1">Error</p>
|
|
<p className="whitespace-pre-wrap">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info Banner */}
|
|
{recipientFpr && activeTab === 'backup' && (
|
|
<div className="p-3 bg-[#16213e] border border-[#9d84b7] rounded-lg text-[#6ef3f7] text-xs shadow-[0_0_10px_rgba(157,132,183,0.3)] flex items-start gap-3 animate-in fade-in">
|
|
<Info size={16} className="shrink-0 mt-0.5" />
|
|
<div>
|
|
<strong>Recipient Key:</strong> <code className="bg-[#16213e] border border-[#00f0ff]/50 px-1.5 py-0.5 rounded font-mono text-[#00f0ff]">{recipientFpr}</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content Grid */}
|
|
<div className="space-y-6">
|
|
<div className="space-y-6">
|
|
{activeTab === 'create' && (
|
|
<div className="space-y-6">
|
|
{/* Seed Length Selector */}
|
|
<div className="space-y-3">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
Seed Length
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
|
|
<button
|
|
onClick={() => setSeedWordCount(12)}
|
|
className={`py-2.5 text-sm rounded-lg font-medium transition-all ${seedWordCount === 12
|
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
|
: 'bg-[#16213e] text-[#9d84b7] border-2 border-[#00f0ff]/30 hover:text-[#6ef3f7] hover:border-[#00f0ff]/50'
|
|
}`}
|
|
style={seedWordCount === 12 ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
|
|
>
|
|
12 Words
|
|
</button>
|
|
<button
|
|
onClick={() => setSeedWordCount(24)}
|
|
className={`py-2.5 text-sm rounded-lg font-medium transition-all ${seedWordCount === 24
|
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
|
: 'bg-[#16213e] text-[#9d84b7] border-2 border-[#00f0ff]/30 hover:text-[#6ef3f7] hover:border-[#00f0ff]/50'
|
|
}`}
|
|
style={seedWordCount === 24 ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}
|
|
>
|
|
24 Words
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Entropy Source Selection */}
|
|
{!entropySource && !generatedSeed && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-bold text-[#00f0ff] uppercase tracking-widest text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
Choose Entropy Source
|
|
</h3>
|
|
<p className="text-[10px] text-[#6ef3f7] text-center">
|
|
All methods enhanced with mouse/keyboard timing + browser crypto
|
|
</p>
|
|
</div>
|
|
{entropyStats && (
|
|
<div className="text-xs text-center text-[#6ef3f7]">
|
|
Last entropy generated: {entropyStats.totalBits} bits
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<button onClick={() => setEntropySource('camera')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
|
|
<div className="flex items-center gap-3">
|
|
<Camera size={24} className="text-[#00f0ff]" />
|
|
<div>
|
|
<p className="text-sm font-bold text-[#00f0ff]">📷 Camera Entropy</p>
|
|
<p className="text-[10px] text-[#6ef3f7]">Point at bright, textured surface</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button onClick={() => setEntropySource('dice')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
|
|
<div className="flex items-center gap-3">
|
|
<Dices size={24} className="text-[#00f0ff]" />
|
|
<div>
|
|
<p className="text-sm font-bold text-[#00f0ff]">🎲 Dice Rolls</p>
|
|
<p className="text-[10px] text-[#6ef3f7]">Roll physical dice 99+ times</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button onClick={() => setEntropySource('audio')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
|
|
<div className="flex items-center gap-3">
|
|
<Mic size={24} className="text-[#00f0ff]" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-bold text-[#00f0ff]">🎤 Audio Noise</p>
|
|
<span className="px-2 py-0.5 bg-[#ff006e] text-white text-[9px] rounded font-bold">BETA</span>
|
|
</div>
|
|
<p className="text-[10px] text-[#6ef3f7]">Capture ambient sound entropy</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<button onClick={() => setEntropySource('randomorg')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
|
|
<div className="flex items-center gap-3">
|
|
<Dices size={24} className="text-[#00f0ff]" />
|
|
<div>
|
|
<p className="text-sm font-bold text-[#00f0ff]">🌐 Random.org D6</p>
|
|
<p className="text-[10px] text-[#6ef3f7]">Manual entropy via random.org</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-2 p-3 bg-[#16213e] border border-[#00f0ff]/30 rounded-lg">
|
|
<Info size={14} className="text-[#00f0ff] shrink-0 mt-0.5" />
|
|
<p className="text-[10px] text-[#6ef3f7]">
|
|
<strong className="text-[#00f0ff]">Privacy:</strong> All processing happens locally in your browser. Images/audio never stored or transmitted. This app is 100% stateless.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Camera Entropy Component */}
|
|
{entropySource === 'camera' && !generatedSeed && (
|
|
<CameraEntropy
|
|
key={`camera-${resetCounter}`} // Force remount on reset
|
|
wordCount={seedWordCount}
|
|
onEntropyGenerated={handleEntropyGenerated}
|
|
onCancel={() => setEntropySource(null)}
|
|
interactionEntropy={interactionEntropyRef.current}
|
|
/>
|
|
)}
|
|
|
|
{/* Dice Entropy Component */}
|
|
{entropySource === 'dice' && !generatedSeed && (
|
|
<DiceEntropy
|
|
key={`dice-${resetCounter}`} // Force remount on reset
|
|
wordCount={seedWordCount}
|
|
onEntropyGenerated={handleEntropyGenerated}
|
|
onCancel={() => setEntropySource(null)}
|
|
interactionEntropy={interactionEntropyRef.current}
|
|
/>
|
|
)}
|
|
|
|
{/* Audio Entropy Component - TODO */}
|
|
{entropySource === 'audio' && !generatedSeed && (
|
|
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#ff006e] text-center">
|
|
<p className="text-sm text-[#ff006e]">Audio entropy coming soon...</p>
|
|
<button onClick={() => setEntropySource(null)} className="mt-4 px-4 py-2 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg text-sm hover:bg-[#ff006e]/20 transition-all">
|
|
Back
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Audio Entropy Component */}
|
|
{entropySource === 'audio' && !generatedSeed && (
|
|
<AudioEntropy
|
|
key={`audio-${resetCounter}`}
|
|
wordCount={seedWordCount}
|
|
onEntropyGenerated={handleEntropyGenerated}
|
|
onCancel={() => setEntropySource(null)}
|
|
interactionEntropy={interactionEntropyRef.current}
|
|
/>
|
|
)}
|
|
|
|
{/* Random.org Entropy Component */}
|
|
{entropySource === 'randomorg' && !generatedSeed && (
|
|
<RandomOrgEntropy
|
|
key={`randomorg-${resetCounter}`}
|
|
wordCount={seedWordCount}
|
|
onEntropyGenerated={handleEntropyGenerated}
|
|
onCancel={() => setEntropySource(null)}
|
|
interactionEntropy={interactionEntropyRef.current}
|
|
/>
|
|
)}
|
|
|
|
{/* Generated Seed Display + Destination Selector */}
|
|
{generatedSeed && (
|
|
<div className="space-y-4">
|
|
<div className="p-6 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-lg shadow-[0_0_30px_rgba(57,255,20,0.4)] space-y-4 animate-in zoom-in-95">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-bold text-sm text-[#39ff14] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}>
|
|
<CheckCircle2 size={20} /> Generated Successfully
|
|
</span>
|
|
</div>
|
|
<div className="relative">
|
|
<p
|
|
className="font-mono text-xs text-[#39ff14] break-words leading-relaxed blur-sensitive"
|
|
title="Hover to reveal seed"
|
|
style={{ textShadow: '0 0 5px rgba(57,255,20,0.5)' }}
|
|
>
|
|
{generatedSeed}
|
|
</p>
|
|
<p className="text-[9px] text-[#6ef3f7] mt-2 text-center">
|
|
👆 Hover to reveal - Write down securely
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Destination Selector */}
|
|
<div className="space-y-3">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
Send Generated Seed To
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
|
|
<label className={`p-3 md:p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'backup' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
|
|
<input type="radio" name="destination" value="backup" checked={seedDestination === 'backup'} onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')} className="hidden" />
|
|
<div className="flex flex-col items-center justify-center gap-1 md:gap-2">
|
|
<Lock className={`w-5 h-5 md:w-6 md:h-6 transition-colors ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`} />
|
|
<div className={`text-xs md:text-sm font-bold ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>Backup</div>
|
|
<p className="text-[10px] text-[#6ef3f7]">Encrypt immediately</p>
|
|
</div>
|
|
</label>
|
|
<label className={`p-3 md:p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'seedblender' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
|
|
<input type="radio" name="destination" value="seedblender" checked={seedDestination === 'seedblender'} onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')} className="hidden" />
|
|
<div className="flex flex-col items-center justify-center gap-1 md:gap-2">
|
|
<Dices className={`w-5 h-5 md:w-6 md:h-6 transition-colors ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`} />
|
|
<div className={`text-xs md:text-sm font-bold ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>Seed Blender</div>
|
|
<p className="text-[10px] text-[#6ef3f7]">Use for XOR blending</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Send Button */}
|
|
<button
|
|
onClick={handleSendToDestination}
|
|
className="w-full py-2.5 bg-[#1a1a2e] border-2 border-[#ff006e] text-[#00f0ff] text-xs md:text-sm rounded-lg font-bold uppercase tracking-wide hover:shadow-[0_0_25px_rgba(255,0,110,0.7)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_15px_rgba(255,0,110,0.5)]"
|
|
style={{ textShadow: '0 0 8px rgba(0,240,255,0.7)' }}
|
|
>
|
|
Continue to {seedDestination === 'backup' ? 'Backup' : 'Seed Blender'}
|
|
</button>
|
|
|
|
<button onClick={() => { setGeneratedSeed(''); setEntropySource(null); setEntropyStats(null); }} className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff]/20 transition-all">
|
|
Generate Another Seed
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className={activeTab === 'backup' ? 'block' : 'hidden'}>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>BIP39 Mnemonic</label>
|
|
<div className="relative">
|
|
<textarea
|
|
value={mnemonic}
|
|
onChange={(e) => setMnemonic(e.target.value)}
|
|
onFocus={(e) => e.target.classList.remove('blur-sensitive')}
|
|
onBlur={(e) => mnemonic && e.target.classList.add('blur-sensitive')}
|
|
placeholder="Enter your 12 or 24 word seed phrase..."
|
|
className={`w-full h-32 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-sm text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all relative overflow-hidden ${mnemonic ? 'blur-sensitive' : ''
|
|
}`}
|
|
style={{
|
|
backgroundImage: 'repeating-linear-gradient(0deg, rgba(0,240,255,0.03) 0px, transparent 1px, transparent 2px, rgba(0,240,255,0.03) 3px)',
|
|
textShadow: '0 0 5px rgba(0,240,255,0.5)'
|
|
}}
|
|
data-sensitive="BIP39 Mnemonic"
|
|
readOnly={isReadOnly}
|
|
/>
|
|
{mnemonic && (
|
|
<p className="text-[9px] text-[#6ef3f7] mt-1">
|
|
👆 Hover or click to reveal
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<PgpKeyInput
|
|
label="PGP Public Key (Optional)"
|
|
icon={FileKey}
|
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- Paste or drag & drop your public key..."
|
|
value={publicKeyInput}
|
|
onChange={setPublicKeyInput}
|
|
readOnly={isReadOnly}
|
|
/>
|
|
</div>
|
|
<div className={activeTab === 'restore' ? 'block' : 'hidden'}>
|
|
<div className="space-y-4">
|
|
{/* File Upload Zone */}
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${isDragging
|
|
? 'border-[#ff006e] bg-[#1a1a2e] shadow-[0_0_30px_rgba(255,0,110,0.5)]'
|
|
: 'border-[#00f0ff50] bg-[#16213e] hover:border-[#00f0ff] hover:bg-[#1a1a2e]'
|
|
}`}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
>
|
|
<div className="space-y-2">
|
|
<div className="text-4xl">📎</div>
|
|
<p className="text-xs text-[#00f0ff] font-medium" style={{ textShadow: '0 0 10px rgba(0,240,255,0.5)' }}>
|
|
Drag & drop file or click Browse
|
|
</p>
|
|
<p className="text-[10px] text-[#6ef3f7]">QR image, PGP armor, hex, numeric</p>
|
|
<div className="flex gap-2 justify-center pt-1">
|
|
<label className="px-3 py-1.5 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-xs font-medium text-[#00f0ff] hover:bg-[#1a1a2e] hover:shadow-[0_0_15px_rgba(0,240,255,0.3)] cursor-pointer transition-all flex items-center gap-2">
|
|
📁 Browse
|
|
<input
|
|
type="file"
|
|
accept="image/*,.txt,.asc"
|
|
className="hidden"
|
|
onChange={(e) => e.target.files?.length && handleFileUpload(e.target.files[0])}
|
|
/>
|
|
</label>
|
|
<button
|
|
onClick={() => setShowQRScanner(true)}
|
|
className="px-3 py-1.5 bg-transparent text-[#00f0ff] rounded-lg font-medium border-2 border-[#00f0ff] hover:bg-[#00f0ff]/10 hover:shadow-[0_0_20px_rgba(0,240,255,0.5)] transition-all flex items-center gap-2 text-xs"
|
|
>
|
|
📷 Scan QR
|
|
</button> </div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Existing restore input textarea stays here */}
|
|
<textarea
|
|
className={`w-full p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-sm text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all relative overflow-hidden`}
|
|
rows={6}
|
|
placeholder="Or paste encrypted data here..."
|
|
value={restoreInput}
|
|
onChange={(e) => setRestoreInput(e.target.value)}
|
|
style={{
|
|
backgroundImage: 'repeating-linear-gradient(0deg, rgba(0,240,255,0.03) 0px, transparent 1px, transparent 2px, rgba(0,240,255,0.03) 3px)',
|
|
textShadow: '0 0 5px rgba(0,240,255,0.5)'
|
|
}}
|
|
/>
|
|
|
|
{/* Auto-detection hint */}
|
|
{detectedMode && (
|
|
<p className="text-xs text-[#00f0ff] flex items-center gap-1">
|
|
<Info size={14} />
|
|
Detected: {detectedMode.toUpperCase()} format
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<PgpKeyInput
|
|
label="PGP Private Key (Optional)"
|
|
icon={FileKey}
|
|
data-sensitive="PGP Private Key"
|
|
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK----- Paste or drag & drop your private key..."
|
|
value={privateKeyInput}
|
|
onChange={setPrivateKeyInput}
|
|
readOnly={isReadOnly}
|
|
/>
|
|
|
|
{privateKeyInput && (
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Private Key Passphrase</label>
|
|
<div className="relative">
|
|
<Lock className="absolute left-3 top-3 text-[#6ef3f7]" size={16} />
|
|
<input
|
|
type="password"
|
|
data-sensitive="Message Password"
|
|
className={`w-full pl-10 pr-4 py-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-[#00f0ff] text-xs placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${isReadOnly ? 'blur-sm select-none' : ''
|
|
}`}
|
|
style={{ textShadow: '0 0 5px rgba(0,240,255,0.5)' }}
|
|
placeholder="Unlock private key..."
|
|
value={privateKeyPassphrase}
|
|
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
|
readOnly={isReadOnly}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={activeTab === 'seedblender' ? 'block' : 'hidden'}>
|
|
<SeedBlender
|
|
key={blenderResetKey}
|
|
onDirtyStateChange={() => { }}
|
|
setMnemonicForBackup={setMnemonic}
|
|
requestTabChange={handleRequestTabChange}
|
|
incomingSeed={seedForBlender}
|
|
onSeedReceived={() => setSeedForBlender('')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={activeTab === 'test-recovery' ? 'block' : 'hidden'}>
|
|
<TestRecovery
|
|
encryptionMode={encryptionMode}
|
|
backupMessagePassword={backupMessagePassword}
|
|
restoreMessagePassword={restoreMessagePassword}
|
|
publicKeyInput={publicKeyInput}
|
|
privateKeyInput={privateKeyInput}
|
|
privateKeyPassphrase={privateKeyPassphrase}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Security Panel */}
|
|
{activeTab !== 'seedblender' && activeTab !== 'create' && (<div className="space-y-2"> {/* Added space-y-2 wrapper */}
|
|
<label className="text-xs font-semibold text-[#00f0ff] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
<Lock size={14} /> SECURITY OPTIONS
|
|
</label>
|
|
|
|
<div className="p-5 bg-[#16213e] rounded-2xl border-2 border-[#00f0ff]/30 shadow-[0_0_20px_rgba(0,240,255,0.2)] space-y-4">
|
|
{/* Removed h3 */}
|
|
|
|
{/* Encryption Mode Toggle */}
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Encryption Mode</label>
|
|
<select
|
|
value={encryptionMode}
|
|
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux' | 'seedqr')}
|
|
disabled={isReadOnly}
|
|
className="w-full px-3 py-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-[#00f0ff] text-xs focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all appearance-none bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOCIgPHBhdGggZD0iTTEgMUw2IDZMMTEgMSIgc3Ryb2tlPSIjMDBmMGZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==')] bg-[length:12px] bg-[position:right_12px_center] bg-no-repeat pr-10"
|
|
style={{ textShadow: '0 0 5px rgba(0,240,255,0.5)' }}
|
|
>
|
|
<option value="pgp">PGP (Asymmetric)</option>
|
|
<option value="krux">Krux KEF (Passphrase)</option>
|
|
<option value="seedqr">SeedQR (Unencrypted)</option>
|
|
</select>
|
|
<p className="text-[10px] text-[#6ef3f7] mt-1">
|
|
{encryptionMode === 'pgp'
|
|
? 'Uses PGP keys or password'
|
|
: 'Uses passphrase only (Krux compatible)'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* SeedQR Format Toggle */}
|
|
{encryptionMode === 'seedqr' && (
|
|
<div className="space-y-2 pt-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>SeedQR Format</label>
|
|
<select
|
|
value={seedQrFormat}
|
|
onChange={(e) => setSeedQrFormat(e.target.value as 'standard' | 'compact')}
|
|
disabled={isReadOnly}
|
|
className="w-full px-3 py-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-[#00f0ff] text-xs focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all appearance-none bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOCIgPHBhdGggZD0iTTEgMUw2IDZMMTEgMSIgc3Ryb2tlPSIjMDBmMGZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==')] bg-[length:12px] bg-[position:right_12px_center] bg-no-repeat pr-10"
|
|
style={{ textShadow: '0 0 5px rgba(0,240,255,0.5)' }}
|
|
>
|
|
<option value="standard">Standard (Numeric)</option>
|
|
<option value="compact">Compact (Binary)</option>
|
|
</select>
|
|
<p className="text-[10px] text-[#6ef3f7] mt-1">
|
|
{seedQrFormat === 'standard'
|
|
? 'Numeric format, human-readable.'
|
|
: 'Compact binary format, smaller QR code.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Krux-specific fields */}
|
|
{encryptionMode === 'krux' && activeTab === 'backup' && (
|
|
<>
|
|
|
|
|
|
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>MESSAGE PASSWORD</label>
|
|
<div className="relative">
|
|
<Lock className="absolute left-3 top-3 text-[#6ef3f7]" size={16} />
|
|
<input
|
|
type="password"
|
|
className={`w-full pl-10 pr-4 py-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-[#00f0ff] text-xs placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${isReadOnly ? 'blur-sm select-none' : ''
|
|
}`}
|
|
style={{ textShadow: '0 0 5px rgba(0,240,255,0.5)' }}
|
|
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-[#6ef3f7] mt-1">
|
|
{encryptionMode === 'krux'
|
|
? 'Required passphrase for Krux encryption'
|
|
: 'Symmetric encryption password (SKESK)'}
|
|
</p>
|
|
</div>
|
|
<div className="pt-3 border-t border-[#00f0ff]/30">
|
|
<div className="flex items-start gap-2 text-xs text-[#6ef3f7]">
|
|
<Info size={14} className="shrink-0 mt-0.5" />
|
|
<p>
|
|
<strong>Krux Compatible Mode:</strong><br />
|
|
Uses wallet fingerprint as salt and 100,000 iterations (Krux defaults).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{activeTab === 'backup' && (
|
|
<div className="pt-3 border-t border-[#00f0ff]/30">
|
|
<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-[#00f0ff] focus:ring-2 focus:ring-[#00f0ff] transition-all"
|
|
/>
|
|
<span className="text-xs font-medium text-[#6ef3f7] group-hover:text-[#00f0ff] 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-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-xl font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-[#ff006e]"
|
|
style={{ textShadow: '0 0 10px rgba(255,255,255,0.8)' }}
|
|
>
|
|
{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-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-xl font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-[#ff006e]"
|
|
style={{ textShadow: '0 0 10px rgba(255,255,255,0.8)' }}
|
|
>
|
|
{loading ? (
|
|
<RefreshCw className="animate-spin" size={20} />
|
|
) : (
|
|
<Unlock size={20} />
|
|
)}
|
|
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)} </div>
|
|
|
|
{/* QR Output */}
|
|
{qrPayload && activeTab === 'backup' && (
|
|
<div className="pt-6 border-t border-[#00f0ff]/20 space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
|
<div className={isReadOnly ? 'blur-lg' : ''}>
|
|
<QrDisplay value={qrPayload} encryptionMode={encryptionMode} fingerprint={recipientFpr} />
|
|
</div>
|
|
|
|
{/* Download Recovery Kit Button */}
|
|
<div className="space-y-2">
|
|
<button
|
|
onClick={handleDownloadRecoveryKit}
|
|
disabled={!qrPayload || loading || isReadOnly}
|
|
className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#00c8ff] text-[#0a0a0f] rounded-xl font-bold text-sm uppercase tracking-wider hover:shadow-[0_0_30px_rgba(0,240,255,0.5)] transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{loading ? (
|
|
<RefreshCw className="animate-spin" size={20} />
|
|
) : (
|
|
<Package size={20} />
|
|
)}
|
|
{loading ? 'Generating...' : '📦 Download Recovery Kit'}
|
|
</button>
|
|
<p className="text-xs text-[#6ef3f7] text-center">
|
|
Contains encrypted backup, recovery scripts, instructions, and BIP39 wordlist
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
Raw payload (copy for backup)
|
|
</label>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => copyToClipboard(qrPayload)}
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#16213e] border-2 border-[#00f0ff]/50 text-[#00f0ff] text-xs font-medium hover:bg-[#1a1a2e] hover:shadow-[0_0_15px_rgba(0,240,255,0.3)] transition-all"
|
|
>
|
|
{copied ? <CheckCircle2 size={14} /> : <QrCode size={14} />}
|
|
{copied ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
|
|
<textarea
|
|
readOnly
|
|
value={typeof qrPayload === 'string' ? qrPayload : Array.from(qrPayload).map(b => b.toString(16).padStart(2, '0')).join('')}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="w-full h-28 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all relative overflow-hidden"
|
|
style={{
|
|
backgroundImage: 'repeating-linear-gradient(0deg, rgba(0,240,255,0.03) 0px, transparent 1px, transparent 2px, rgba(0,240,255,0.03) 3px)',
|
|
textShadow: '0 0 5px rgba(0,240,255,0.5)'
|
|
}}
|
|
/>
|
|
<p className="text-[11px] text-[#6ef3f7]">
|
|
Tip: click the box to select all, or use Copy.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Restored Mnemonic */}
|
|
{decryptedRestoredMnemonic && activeTab === 'restore' && (
|
|
<div className="pt-6 border-t border-[#00f0ff]/20 animate-in zoom-in-95">
|
|
<div className="p-6 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-lg shadow-[0_0_30px_rgba(57,255,20,0.4)] relative overflow-hidden">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="font-bold text-[#39ff14] flex items-center gap-2 text-lg" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}>
|
|
<CheckCircle2 size={22} /> Mnemonic Recovered
|
|
</span>
|
|
<button
|
|
onClick={() => setDecryptedRestoredMnemonic(null)}
|
|
className="p-2.5 hover:bg-[#16213e] rounded-xl transition-all text-[#39ff14] hover:shadow-[0_0_15px_rgba(57,255,20,0.5)] flex items-center gap-2"
|
|
>
|
|
<EyeOff size={22} /> Hide
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_20px_rgba(57,255,20,0.3)]">
|
|
<div className="relative">
|
|
<p
|
|
className="font-mono text-center text-base break-words text-[#39ff14] blur-sensitive"
|
|
title="Hover to reveal"
|
|
style={{ textShadow: '0 0 8px rgba(57,255,20,0.8)' }}
|
|
>
|
|
{decryptedRestoredMnemonic}
|
|
</p>
|
|
<p className="text-[9px] text-[#6ef3f7] mt-2 text-center">
|
|
👆 Hover to reveal decrypted seed
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div> {/* Close new cyberpunk main container */}
|
|
</main >
|
|
<Footer
|
|
appVersion={__APP_VERSION__}
|
|
buildHash={__BUILD_HASH__}
|
|
buildTimestamp={__BUILD_TIMESTAMP__}
|
|
/>
|
|
|
|
{/* QR Scanner Modal */}
|
|
{showQRScanner && (
|
|
<QRScanner
|
|
onScanSuccess={handleRestoreScanSuccess}
|
|
onClose={handleRestoreClose}
|
|
onError={handleRestoreError}
|
|
/>
|
|
)}
|
|
|
|
{/* Security Modal */}
|
|
{showSecurityModal && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
onClick={() => setShowSecurityModal(false)}
|
|
>
|
|
<div
|
|
className="bg-[#1a1a2e] rounded-xl border-2 border-[#00f0ff]/30 p-6 max-w-md w-full mx-4 shadow-[0_0_30px_rgba(0,240,255,0.3)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-semibold text-[#00f0ff] mb-4">Security Limitations</h3>
|
|
<div className="text-sm text-[#6ef3f7] space-y-2">
|
|
<SecurityWarnings />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Storage Modal */}
|
|
{showStorageModal && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
onClick={() => setShowStorageModal(false)}
|
|
>
|
|
<div
|
|
className="bg-[#1a1a2e] rounded-xl border-2 border-[#00f0ff]/30 p-6 max-w-md w-full mx-4 shadow-[0_0_30px_rgba(0,240,255,0.3)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-semibold text-[#00f0ff] mb-4">Storage Details</h3>
|
|
<div className="text-sm text-[#6ef3f7] space-y-2">
|
|
<StorageDetails localItems={localItems} sessionItems={sessionItems} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Clipboard Modal */}
|
|
{showClipboardModal && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
onClick={() => setShowClipboardModal(false)}
|
|
>
|
|
<div
|
|
className="bg-[#1a1a2e] rounded-xl border-2 border-[#00f0ff]/30 p-6 max-w-md w-full mx-4 shadow-[0_0_30px_rgba(0,240,255,0.3)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-semibold text-[#00f0ff] mb-4">Clipboard Activity</h3>
|
|
<div className="text-sm text-[#6ef3f7] space-y-2">
|
|
<ClipboardDetails events={clipboardEvents} onClear={clearClipboard} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Lock Confirmation Modal */}
|
|
{showLockConfirm && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
onClick={() => setShowLockConfirm(false)}
|
|
>
|
|
<div
|
|
className="bg-[#1a1a2e] rounded-xl border-2 border-[#ff006e] p-6 max-w-md w-full mx-4 shadow-[0_0_30px_rgba(255,0,110,0.3)]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-semibold text-[#ff006e] mb-4 flex items-center gap-2">
|
|
<Lock className="w-5 h-5 text-[#ff006e]" />
|
|
Lock Sensitive Data?
|
|
</h3>
|
|
<div className="text-sm text-[#6ef3f7] space-y-3 mb-6">
|
|
<p>This will:</p>
|
|
<ul className="list-disc list-inside space-y-1 text-[#9d84b7]">
|
|
<li>Blur all sensitive data (mnemonics, keys, passwords)</li>
|
|
<li>Disable all inputs</li>
|
|
<li>Prevent clipboard operations</li>
|
|
</ul>
|
|
<p className="text-xs text-[#6ef3f7] mt-2">
|
|
Use this when showing the app to others or stepping away from your device.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
className="flex-1 py-2 bg-[#16213e] hover:bg-[#1a1a2e] text-[#6ef3f7] rounded-lg transition-all border-2 border-[#00f0ff]/30 hover:border-[#00f0ff]/50"
|
|
onClick={() => setShowLockConfirm(false)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="flex-1 py-2 bg-[#ff006e] hover:bg-[#ff4d8f] text-white font-semibold rounded-lg transition-all hover:shadow-[0_0_15px_rgba(255,0,110,0.5)]"
|
|
onClick={() => setIsReadOnly(true)}
|
|
>
|
|
Lock Data
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div> {/* Close relative z-10 content wrapper */}
|
|
</div>
|
|
</div> /* Close root min-h-screen */
|
|
);
|
|
}
|
|
|
|
export default App; |