story-research-zapwall/components/KeyManagementManager.tsx
2026-01-06 22:48:58 +01:00

424 lines
16 KiB
TypeScript

import { useState, useEffect } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { keyManagementService } from '@/lib/keyManagement'
import { nip19 } from 'nostr-tools'
import { t } from '@/lib/i18n'
import { SyncProgressBar } from './SyncProgressBar'
interface PublicKeys {
publicKey: string
npub: string
}
export function KeyManagementManager(): React.ReactElement {
console.warn('[KeyManagementManager] Component rendered')
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
const [accountExists, setAccountExists] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [importKey, setImportKey] = useState('')
const [importing, setImporting] = useState(false)
const [showImportForm, setShowImportForm] = useState(false)
const [showReplaceWarning, setShowReplaceWarning] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[] | null>(null)
const [newNpub, setNewNpub] = useState<string | null>(null)
const [copiedNpub, setCopiedNpub] = useState(false)
const [copiedPublicKey, setCopiedPublicKey] = useState(false)
const [copiedRecoveryPhrase, setCopiedRecoveryPhrase] = useState(false)
useEffect(() => {
void loadKeys()
}, [])
async function loadKeys(): Promise<void> {
try {
setLoading(true)
setError(null)
const exists = await nostrAuthService.accountExists()
setAccountExists(exists)
if (exists) {
const keys = await keyManagementService.getPublicKeys()
if (keys) {
setPublicKeys(keys)
}
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.loading')
setError(errorMessage)
console.error('Error loading keys:', e)
} finally {
setLoading(false)
}
}
function extractKeyFromUrl(url: string): string | null {
try {
// Try to parse as URL
const urlObj = new URL(url)
// Check if it's a nostr:// URL with nsec
if (urlObj.protocol === 'nostr:' || urlObj.protocol === 'nostr://') {
const path = urlObj.pathname || urlObj.href.replace(/^nostr:?\/\//, '')
if (path.startsWith('nsec')) {
return path
}
}
// Check if URL contains nsec
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
if (nsecMatch) {
return nsecMatch[0]
}
return null
} catch {
// Not a valid URL, try to extract nsec from text
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
if (nsecMatch) {
return nsecMatch[0]
}
// Assume it's already a key (hex or nsec)
return url.trim()
}
}
async function handleImport(): Promise<void> {
if (!importKey.trim()) {
setError(t('settings.keyManagement.import.error.required'))
return
}
// Extract key from URL or text
const extractedKey = extractKeyFromUrl(importKey.trim())
if (!extractedKey) {
setError(t('settings.keyManagement.import.error.invalid'))
return
}
// Validate key format
try {
// Try to decode as nsec
const decoded = nip19.decode(extractedKey)
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format')
}
// decoded.data can be string (hex) or Uint8Array, both are valid
if (typeof decoded.data !== 'string' && !(decoded.data instanceof Uint8Array)) {
throw new Error('Invalid nsec format')
}
} catch {
// If decoding failed, assume it's hex, validate length (64 hex chars = 32 bytes)
if (!/^[0-9a-f]{64}$/i.test(extractedKey)) {
setError(t('settings.keyManagement.import.error.invalid'))
return
}
}
// If account exists, show warning
if (accountExists) {
setShowReplaceWarning(true)
return
}
await performImport(extractedKey)
}
async function performImport(key: string): Promise<void> {
try {
setImporting(true)
setError(null)
setShowReplaceWarning(false)
// If account exists, delete it first
if (accountExists) {
await nostrAuthService.deleteAccount()
}
// Create new account with imported key
const result = await nostrAuthService.createAccount(key)
setRecoveryPhrase(result.recoveryPhrase)
setNewNpub(result.npub)
setImportKey('')
setShowImportForm(false)
await loadKeys()
// Sync user content to IndexedDB cache
if (result.publicKey) {
const { syncUserContentToCache } = await import('@/lib/userContentSync')
void syncUserContentToCache(result.publicKey)
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : t('settings.keyManagement.import.error.failed')
setError(errorMessage)
console.error('Error importing key:', e)
} finally {
setImporting(false)
}
}
async function handleCopyRecoveryPhrase(): Promise<void> {
if (!recoveryPhrase) {
return
}
try {
await navigator.clipboard.writeText(recoveryPhrase.join(' '))
setCopiedRecoveryPhrase(true)
setTimeout(() => {
setCopiedRecoveryPhrase(false)
}, 2000)
} catch (e) {
console.error('Error copying recovery phrase:', e)
}
}
async function handleCopyNpub(): Promise<void> {
if (!publicKeys?.npub) {
return
}
try {
await navigator.clipboard.writeText(publicKeys.npub)
setCopiedNpub(true)
setTimeout(() => {
setCopiedNpub(false)
}, 2000)
} catch (e) {
console.error('Error copying npub:', e)
}
}
async function handleCopyPublicKey(): Promise<void> {
if (!publicKeys?.publicKey) {
return
}
try {
await navigator.clipboard.writeText(publicKeys.publicKey)
setCopiedPublicKey(true)
setTimeout(() => {
setCopiedPublicKey(false)
}, 2000)
} catch (e) {
console.error('Error copying public key:', e)
}
}
if (loading) {
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<p className="text-cyber-accent">{t('settings.keyManagement.loading')}</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">{t('settings.keyManagement.title')}</h2>
{error && (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Public Keys Display */}
{publicKeys && (
<div className="space-y-4 mb-6">
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">{t('settings.keyManagement.publicKey.npub')}</p>
<button
onClick={() => {
void handleCopyNpub()
}}
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
>
{copiedNpub ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.npub}</p>
</div>
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">{t('settings.keyManagement.publicKey.hex')}</p>
<button
onClick={() => {
void handleCopyPublicKey()
}}
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
>
{copiedPublicKey ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.publicKey}</p>
</div>
</div>
)}
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
{(() => {
console.warn('[KeyManagementManager] Rendering SyncProgressBar')
return <SyncProgressBar />
})()}
{!publicKeys && !accountExists && (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
<p className="text-yellow-300/90 text-sm">
{t('settings.keyManagement.noAccount.description')}
</p>
</div>
)}
{/* Import Form */}
{!showImportForm && (
<button
onClick={() => {
setShowImportForm(true)
setError(null)
}}
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
</button>
)}
{showImportForm && (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.import.warning.title')}</p>
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.description') }} />
{accountExists && (
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.import.warning.replace') }} />
)}
</div>
<div>
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
{t('settings.keyManagement.import.label')}
</label>
<textarea
id="importKey"
value={importKey}
onChange={(e) => {
setImportKey(e.target.value)
setError(null)
}}
placeholder={t('settings.keyManagement.import.placeholder')}
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
rows={4}
/>
<p className="text-sm text-cyber-accent/70 mt-2">
{t('settings.keyManagement.import.help')}
</p>
</div>
{showReplaceWarning && (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
<p className="text-red-400 font-semibold mb-2">{t('settings.keyManagement.replace.warning.title')}</p>
<p className="text-red-300/90 text-sm mb-4">
{t('settings.keyManagement.replace.warning.description')}
</p>
<div className="flex gap-4">
<button
onClick={() => {
setShowReplaceWarning(false)
}}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
{t('settings.keyManagement.replace.cancel')}
</button>
<button
onClick={() => {
void performImport(extractKeyFromUrl(importKey.trim()) || importKey.trim())
}}
disabled={importing}
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
>
{importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
</button>
</div>
</div>
)}
{!showReplaceWarning && (
<div className="flex gap-4">
<button
onClick={() => {
setShowImportForm(false)
setImportKey('')
setError(null)
}}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
{t('settings.keyManagement.import.cancel')}
</button>
<button
onClick={() => {
void handleImport()
}}
disabled={importing}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
</button>
</div>
)}
</div>
)}
{/* Recovery Phrase Display (after import) */}
{recoveryPhrase && newNpub && (
<div className="mt-6 space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.recovery.warning.title')}</p>
<p className="text-yellow-300/90 text-sm" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part1') }} />
<p className="text-yellow-300/90 text-sm mt-2" dangerouslySetInnerHTML={{ __html: t('settings.keyManagement.recovery.warning.part2') }} />
<p className="text-yellow-300/90 text-sm mt-2">
{t('settings.keyManagement.recovery.warning.part3')}
</p>
</div>
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<div className="grid grid-cols-2 gap-4 mb-4">
{recoveryPhrase.map((word, index) => (
<div
key={`recovery-word-${index}-${word}`}
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
>
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
<span className="font-semibold text-neon-cyan">{word}</span>
</div>
))}
</div>
<button
onClick={() => {
void handleCopyRecoveryPhrase()
}}
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
>
{copiedRecoveryPhrase ? t('settings.keyManagement.recovery.copied') : t('settings.keyManagement.recovery.copy')}
</button>
</div>
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<p className="text-neon-blue font-semibold mb-2">{t('settings.keyManagement.recovery.newNpub')}</p>
<p className="text-neon-cyan text-sm font-mono break-all">{newNpub}</p>
</div>
<button
onClick={() => {
setRecoveryPhrase(null)
setNewNpub(null)
void loadKeys()
}}
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{t('settings.keyManagement.recovery.done')}
</button>
</div>
)}
</div>
</div>
)
}