mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
feat: Implement 'Lock/Edit' mode with blur and confirmation dialog
This commit is contained in:
122
src/App.tsx
122
src/App.tsx
@@ -17,11 +17,11 @@ import { validateBip39Mnemonic } from './lib/bip39';
|
|||||||
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
import { buildPlaintext, encryptToSeedPgp, decryptSeedPgp } from './lib/seedpgp';
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
import { SecurityWarnings } from './components/SecurityWarnings';
|
import { SecurityWarnings } from './components/SecurityWarnings';
|
||||||
import { ReadOnly } from './components/ReadOnly';
|
|
||||||
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
|
import { getSessionKey, encryptJsonToBlob, destroySessionKey, EncryptedBlob } from './lib/sessionCrypto';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import { StorageDetails } from './components/StorageDetails';
|
import { StorageDetails } from './components/StorageDetails';
|
||||||
import { ClipboardDetails } from './components/ClipboardDetails';
|
import { ClipboardDetails } from './components/ClipboardDetails';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
|
||||||
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
console.log("OpenPGP.js version:", openpgp.config.versionString);
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ function App() {
|
|||||||
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
|
const [localItems, setLocalItems] = useState<StorageItem[]>([]);
|
||||||
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
|
const [sessionItems, setSessionItems] = useState<StorageItem[]>([]);
|
||||||
const [clipboardEvents, setClipboardEvents] = useState<ClipboardEvent[]>([]);
|
const [clipboardEvents, setClipboardEvents] = useState<ClipboardEvent[]>([]);
|
||||||
|
const [showLockConfirm, setShowLockConfirm] = useState(false);
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
|
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
|
||||||
|
|
||||||
@@ -100,21 +101,7 @@ function App() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// When entering read-only mode, clear sensitive data for security.
|
|
||||||
if (isReadOnly) {
|
|
||||||
setMnemonic('');
|
|
||||||
setBackupMessagePassword('');
|
|
||||||
setRestoreMessagePassword('');
|
|
||||||
setPublicKeyInput('');
|
|
||||||
setPrivateKeyInput('');
|
|
||||||
setPrivateKeyPassphrase('');
|
|
||||||
setQrPayload('');
|
|
||||||
setRestoreInput('');
|
|
||||||
setDecryptedRestoredMnemonic(null);
|
|
||||||
setError('');
|
|
||||||
}
|
|
||||||
}, [isReadOnly]);
|
|
||||||
|
|
||||||
// Cleanup session key on component unmount
|
// Cleanup session key on component unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -319,6 +306,21 @@ function App() {
|
|||||||
setShowQRScanner(false);
|
setShowQRScanner(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleLock = () => {
|
||||||
|
if (!isReadOnly) {
|
||||||
|
// About to lock - show confirmation
|
||||||
|
setShowLockConfirm(true);
|
||||||
|
} else {
|
||||||
|
// Unlocking - no confirmation needed
|
||||||
|
setIsReadOnly(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmLock = () => {
|
||||||
|
setIsReadOnly(true);
|
||||||
|
setShowLockConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-800 text-slate-100">
|
<div className="min-h-screen bg-slate-800 text-slate-100">
|
||||||
@@ -334,6 +336,8 @@ function App() {
|
|||||||
encryptedMnemonicCache={encryptedMnemonicCache}
|
encryptedMnemonicCache={encryptedMnemonicCache}
|
||||||
handleLockAndClear={handleLockAndClear}
|
handleLockAndClear={handleLockAndClear}
|
||||||
appVersion={__APP_VERSION__}
|
appVersion={__APP_VERSION__}
|
||||||
|
isLocked={isReadOnly}
|
||||||
|
onToggleLock={handleToggleLock}
|
||||||
/>
|
/>
|
||||||
<main className="max-w-7xl mx-auto px-6 py-4">
|
<main className="max-w-7xl mx-auto px-6 py-4">
|
||||||
<div className="p-6 md:p-8 space-y-6">
|
<div className="p-6 md:p-8 space-y-6">
|
||||||
@@ -350,10 +354,10 @@ function App() {
|
|||||||
|
|
||||||
{/* Info Banner */}
|
{/* Info Banner */}
|
||||||
{recipientFpr && activeTab === 'backup' && (
|
{recipientFpr && activeTab === 'backup' && (
|
||||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3 text-blue-800 text-xs animate-in fade-in">
|
<div className="p-3 bg-teal-50 border border-teal-200 rounded-lg flex items-start gap-3 text-teal-800 text-xs animate-in fade-in">
|
||||||
<Info size={16} className="shrink-0 mt-0.5" />
|
<Info size={16} className="shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Recipient Key:</strong> <code className="bg-blue-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
|
<strong>Recipient Key:</strong> <code className="bg-teal-100 px-1.5 py-0.5 rounded font-mono">{recipientFpr}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -364,9 +368,11 @@ function App() {
|
|||||||
{activeTab === 'backup' ? (
|
{activeTab === 'backup' ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-slate-700">BIP39 Mnemonic</label>
|
<label className="text-sm font-semibold text-slate-200">BIP39 Mnemonic</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
className={`w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none ${
|
||||||
|
isReadOnly ? 'blur-sm select-none' : ''
|
||||||
|
}`}
|
||||||
|
|
||||||
data-sensitive="BIP39 Mnemonic"
|
data-sensitive="BIP39 Mnemonic"
|
||||||
placeholder="Enter your 12 or 24 word seed phrase..."
|
placeholder="Enter your 12 or 24 word seed phrase..."
|
||||||
@@ -399,9 +405,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-slate-700">SEEDPGP1 Payload</label>
|
<label className="text-sm font-semibold text-slate-200">SEEDPGP1 Payload</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
className={`w-full h-32 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none ${
|
||||||
|
isReadOnly ? 'blur-sm select-none' : ''
|
||||||
|
}`}
|
||||||
|
|
||||||
placeholder="SEEDPGP1:0:ABCD:..."
|
placeholder="SEEDPGP1:0:ABCD:..."
|
||||||
value={restoreInput}
|
value={restoreInput}
|
||||||
@@ -428,7 +436,9 @@ function App() {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
data-sensitive="Message Password"
|
data-sensitive="Message 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-blue-500 transition-all"
|
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="Unlock private key..."
|
placeholder="Unlock private key..."
|
||||||
value={privateKeyPassphrase}
|
value={privateKeyPassphrase}
|
||||||
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
onChange={(e) => setPrivateKeyPassphrase(e.target.value)}
|
||||||
@@ -443,7 +453,7 @@ function App() {
|
|||||||
|
|
||||||
{/* Security Panel */}
|
{/* Security Panel */}
|
||||||
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
|
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
|
||||||
<label className="text-sm font-semibold text-slate-700 flex items-center gap-2">
|
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
|
||||||
<Lock size={14} /> SECURITY OPTIONS
|
<Lock size={14} /> SECURITY OPTIONS
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -456,7 +466,9 @@ function App() {
|
|||||||
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
|
||||||
<input
|
<input
|
||||||
type="password"
|
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-blue-500 transition-all"
|
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="Optional password..."
|
placeholder="Optional password..."
|
||||||
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
|
||||||
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
|
||||||
@@ -475,7 +487,7 @@ function App() {
|
|||||||
checked={hasBip39Passphrase}
|
checked={hasBip39Passphrase}
|
||||||
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
|
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">
|
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||||
BIP39 25th word active
|
BIP39 25th word active
|
||||||
@@ -484,12 +496,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ReadOnly
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
onToggle={setIsReadOnly}
|
|
||||||
appVersion={__APP_VERSION__}
|
|
||||||
buildHash={__BUILD_HASH__}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
@@ -497,7 +503,7 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleBackup}
|
onClick={handleBackup}
|
||||||
disabled={!mnemonic || loading || isReadOnly}
|
disabled={!mnemonic || loading || isReadOnly}
|
||||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-blue-600 disabled:hover:to-blue-700"
|
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 ? (
|
{loading ? (
|
||||||
<RefreshCw className="animate-spin" size={20} />
|
<RefreshCw className="animate-spin" size={20} />
|
||||||
@@ -526,7 +532,7 @@ function App() {
|
|||||||
{/* QR Output */}
|
{/* QR Output */}
|
||||||
{qrPayload && activeTab === 'backup' && (
|
{qrPayload && activeTab === 'backup' && (
|
||||||
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
<div className="pt-6 border-t border-slate-200 space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
||||||
<div className="flex justify-center">
|
<div className={isReadOnly ? 'blur-lg' : ''}>
|
||||||
<QrDisplay value={qrPayload} />
|
<QrDisplay value={qrPayload} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -549,7 +555,7 @@ function App() {
|
|||||||
readOnly
|
readOnly
|
||||||
value={qrPayload}
|
value={qrPayload}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 placeholder:text-slate-500 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full h-28 p-3 bg-slate-900 rounded-xl font-mono text-[10px] text-green-400 placeholder:text-slate-500 border border-slate-700 shadow-inner leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
Tip: click the box to select all, or use Copy.
|
Tip: click the box to select all, or use Copy.
|
||||||
@@ -575,7 +581,9 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
|
<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">
|
<p className={`font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words ${
|
||||||
|
isReadOnly ? 'blur-md select-none' : ''
|
||||||
|
}`}>
|
||||||
{decryptedRestoredMnemonic}
|
{decryptedRestoredMnemonic}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,6 +592,11 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<Footer
|
||||||
|
appVersion={__APP_VERSION__}
|
||||||
|
buildHash={__BUILD_HASH__}
|
||||||
|
buildTimestamp={__BUILD_TIMESTAMP__}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* QR Scanner Modal */}
|
{/* QR Scanner Modal */}
|
||||||
{showQRScanner && (
|
{showQRScanner && (
|
||||||
@@ -650,6 +663,43 @@ function App() {
|
|||||||
</div>
|
</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">
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Lock className="w-5 h-5 text-amber-500" />
|
||||||
|
Lock Sensitive Data?
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-slate-300 space-y-3 mb-6">
|
||||||
|
<p>This will:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-slate-400">
|
||||||
|
<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-slate-500 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-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
|
||||||
|
onClick={() => setShowLockConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 py-2 bg-amber-500 hover:bg-amber-600 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
onClick={confirmLock}
|
||||||
|
>
|
||||||
|
Lock Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/components/Footer.tsx
Normal file
18
src/components/Footer.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
appVersion: string;
|
||||||
|
buildHash: string;
|
||||||
|
buildTimestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer: React.FC<FooterProps> = ({ appVersion, buildHash, buildTimestamp }) => {
|
||||||
|
return (
|
||||||
|
<footer className="text-center text-xs text-slate-400 p-4">
|
||||||
|
<p>SeedPGP v{appVersion} • build {buildHash} • {buildTimestamp}</p>
|
||||||
|
<p className="mt-1">Never share your private keys or seed phrases. Always verify on an airgapped device.</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -3,6 +3,7 @@ import { Shield, Lock } from 'lucide-react';
|
|||||||
import SecurityBadge from './badges/SecurityBadge';
|
import SecurityBadge from './badges/SecurityBadge';
|
||||||
import StorageBadge from './badges/StorageBadge';
|
import StorageBadge from './badges/StorageBadge';
|
||||||
import ClipboardBadge from './badges/ClipboardBadge';
|
import ClipboardBadge from './badges/ClipboardBadge';
|
||||||
|
import EditLockBadge from './badges/EditLockBadge';
|
||||||
|
|
||||||
interface StorageItem {
|
interface StorageItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -29,6 +30,8 @@ interface HeaderProps {
|
|||||||
encryptedMnemonicCache: any;
|
encryptedMnemonicCache: any;
|
||||||
handleLockAndClear: () => void;
|
handleLockAndClear: () => void;
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
|
isLocked: boolean;
|
||||||
|
onToggleLock: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({
|
const Header: React.FC<HeaderProps> = ({
|
||||||
@@ -42,7 +45,9 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
encryptedMnemonicCache,
|
encryptedMnemonicCache,
|
||||||
handleLockAndClear,
|
handleLockAndClear,
|
||||||
appVersion
|
appVersion,
|
||||||
|
isLocked,
|
||||||
|
onToggleLock
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-slate-900 border-b border-slate-800 backdrop-blur-sm">
|
<header className="sticky top-0 z-50 bg-slate-900 border-b border-slate-800 backdrop-blur-sm">
|
||||||
@@ -50,12 +55,12 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left: Logo & Title */}
|
{/* Left: Logo & Title */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-teal-500 rounded-lg flex items-center justify-center">
|
||||||
<Shield className="w-6 h-6 text-white" />
|
<Shield className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-white">
|
<h1 className="text-lg font-semibold text-white">
|
||||||
SeedPGP <span className="text-blue-400">v{appVersion}</span>
|
SeedPGP <span className="text-teal-400">v{appVersion}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-400">OpenPGP-secured BIP39 backup</p>
|
<p className="text-xs text-slate-400">OpenPGP-secured BIP39 backup</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +75,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
<div onClick={onOpenClipboardModal} className="cursor-pointer">
|
<div onClick={onOpenClipboardModal} className="cursor-pointer">
|
||||||
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
|
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
|
||||||
</div>
|
</div>
|
||||||
|
<EditLockBadge isLocked={isLocked} onToggle={onToggleLock} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Action Buttons */}
|
{/* Right: Action Buttons */}
|
||||||
@@ -84,13 +90,13 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-blue-500 hover:bg-blue-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
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={() => setActiveTab('backup')}
|
||||||
>
|
>
|
||||||
Backup
|
Backup
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-blue-500 hover:bg-blue-600' : 'bg-slate-700 hover:bg-slate-600'}`}
|
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={() => setActiveTab('restore')}
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
@@ -107,6 +113,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
<div onClick={onOpenClipboardModal} className="cursor-pointer">
|
<div onClick={onOpenClipboardModal} className="cursor-pointer">
|
||||||
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
|
<ClipboardBadge events={events} onOpenClipboardModal={onOpenClipboardModal} />
|
||||||
</div>
|
</div>
|
||||||
|
<EditLockBadge isLocked={isLocked} onToggle={onToggleLock} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-slate-700 flex items-center justify-between">
|
<label className="text-sm font-semibold text-slate-200 flex items-center justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{Icon && <Icon size={14} />} {label}
|
{Icon && <Icon size={14} />} {label}
|
||||||
</span>
|
</span>
|
||||||
@@ -69,7 +69,8 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 ${isDragging && !readOnly ? 'border-blue-500 bg-blue-50' : 'border-slate-200'
|
className={`w-full h-40 p-3 bg-slate-50 border rounded-xl text-xs font-mono transition-colors resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 ${isDragging && !readOnly ? 'border-teal-500 bg-teal-50' : 'border-slate-200'} ${
|
||||||
|
readOnly ? 'blur-sm select-none' : ''
|
||||||
}`}
|
}`}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -77,8 +78,8 @@ export const PgpKeyInput: React.FC<PgpKeyInputProps> = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{isDragging && !readOnly && (
|
{isDragging && !readOnly && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-50/90 rounded-xl border-2 border-dashed border-blue-500 pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-teal-50/90 rounded-xl border-2 border-dashed border-teal-500 pointer-events-none z-10">
|
||||||
<div className="text-blue-600 font-bold flex flex-col items-center animate-bounce">
|
<div className="text-teal-600 font-bold flex flex-col items-center animate-bounce">
|
||||||
<Upload size={24} />
|
<Upload size={24} />
|
||||||
<span className="text-sm mt-2">Drop Key File Here</span>
|
<span className="text-sm mt-2">Drop Key File Here</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={startCamera}
|
onClick={startCamera}
|
||||||
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg"
|
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg"
|
||||||
>
|
>
|
||||||
<Camera size={20} />
|
<Camera size={20} />
|
||||||
Use Camera
|
Use Camera
|
||||||
@@ -184,7 +184,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
|||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="pt-4 border-t border-slate-200">
|
<div className="pt-4 border-t border-slate-200">
|
||||||
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
|
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
|
||||||
<Info size={14} className="shrink-0 mt-0.5 text-blue-600" />
|
<Info size={14} className="shrink-0 mt-0.5 text-teal-600" />
|
||||||
<div>
|
<div>
|
||||||
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
|
||||||
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
|
||||||
@@ -210,7 +210,7 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
|
|||||||
{/* File Processing View */}
|
{/* File Processing View */}
|
||||||
{scanMode === 'file' && scanning && (
|
{scanMode === 'file' && scanning && (
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-blue-600"></div>
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-teal-600"></div>
|
||||||
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
|
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function ReadOnly({ isReadOnly, onToggle, buildHash, appVersion }: ReadOn
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isReadOnly}
|
checked={isReadOnly}
|
||||||
onChange={(e) => onToggle(e.target.checked)}
|
onChange={(e) => onToggle(e.target.checked)}
|
||||||
className="rounded text-blue-600 focus:ring-2 focus:ring-blue-500 transition-all"
|
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">
|
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||||
Read-only Mode
|
Read-only Mode
|
||||||
|
|||||||
28
src/components/badges/EditLockBadge.tsx
Normal file
28
src/components/badges/EditLockBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Lock, Unlock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EditLockBadgeProps {
|
||||||
|
isLocked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditLockBadge: React.FC<EditLockBadgeProps> = ({ isLocked, onToggle }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all hover:scale-105 ${
|
||||||
|
isLocked
|
||||||
|
? 'text-amber-500 bg-amber-500/10 border-amber-500/30 font-semibold'
|
||||||
|
: 'text-green-500 bg-green-500/10 border-green-500/30'
|
||||||
|
}`}
|
||||||
|
title={isLocked ? 'Click to unlock and edit' : 'Click to lock and blur sensitive data'}
|
||||||
|
>
|
||||||
|
{isLocked ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{isLocked ? 'Locked' : 'Edit'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditLockBadge;
|
||||||
@@ -20,7 +20,7 @@ const StorageBadge: React.FC<StorageBadgeProps> = ({ localItems, sessionItems })
|
|||||||
const status = sensitiveCount > 0 ? 'Warning' : totalItems > 0 ? 'Active' : 'Empty';
|
const status = sensitiveCount > 0 ? 'Warning' : totalItems > 0 ? 'Active' : 'Empty';
|
||||||
const colorClass =
|
const colorClass =
|
||||||
status === 'Warning' ? 'text-amber-500/80' :
|
status === 'Warning' ? 'text-amber-500/80' :
|
||||||
status === 'Active' ? 'text-blue-500/80' :
|
status === 'Active' ? 'text-teal-500/80' :
|
||||||
'text-green-500/80';
|
'text-green-500/80';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -8,3 +8,4 @@ declare module '*.css' {
|
|||||||
|
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
declare const __BUILD_HASH__: string;
|
declare const __BUILD_HASH__: string;
|
||||||
|
declare const __BUILD_TIMESTAMP__: string;
|
||||||
|
|||||||
@@ -29,5 +29,6 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
'__APP_VERSION__': JSON.stringify(appVersion),
|
'__APP_VERSION__': JSON.stringify(appVersion),
|
||||||
'__BUILD_HASH__': JSON.stringify(gitHash),
|
'__BUILD_HASH__': JSON.stringify(gitHash),
|
||||||
|
'__BUILD_TIMESTAMP__': JSON.stringify(new Date().toISOString()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user