560 lines
18 KiB
TypeScript
560 lines
18 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 {
|
|
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 {
|
|
return extractKeyFromText(url)
|
|
}
|
|
}
|
|
|
|
function extractKeyFromText(text: string): string {
|
|
const nsec = extractNsec(text)
|
|
if (nsec) {
|
|
return nsec
|
|
}
|
|
return text.trim()
|
|
}
|
|
|
|
function extractNsec(text: string): string | null {
|
|
const nsecMatch = text.match(/nsec1[a-z0-9]+/i)
|
|
return nsecMatch?.[0] ?? null
|
|
}
|
|
|
|
function isValidPrivateKeyFormat(key: string): boolean {
|
|
try {
|
|
const decoded = nip19.decode(key)
|
|
if (decoded.type !== 'nsec') {
|
|
return false
|
|
}
|
|
return typeof decoded.data === 'string' || decoded.data instanceof Uint8Array
|
|
} catch {
|
|
return /^[0-9a-f]{64}$/i.test(key)
|
|
}
|
|
}
|
|
|
|
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
|
|
if (!isValidPrivateKeyFormat(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 via Service Worker
|
|
if (result.publicKey) {
|
|
const { swClient } = await import('@/lib/swClient')
|
|
const isReady = await swClient.isReady()
|
|
if (isReady) {
|
|
void swClient.startUserSync(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>
|
|
|
|
<KeyManagementErrorBanner error={error} />
|
|
|
|
<KeyManagementPublicKeysPanel
|
|
publicKeys={publicKeys}
|
|
copiedNpub={copiedNpub}
|
|
copiedPublicKey={copiedPublicKey}
|
|
onCopyNpub={handleCopyNpub}
|
|
onCopyPublicKey={handleCopyPublicKey}
|
|
/>
|
|
|
|
{/* Sync Progress Bar - Always show if connected, even if publicKeys not loaded yet */}
|
|
<SyncProgressBar />
|
|
|
|
<KeyManagementNoAccountBanner publicKeys={publicKeys} accountExists={accountExists} />
|
|
|
|
<KeyManagementImportButton
|
|
accountExists={accountExists}
|
|
showImportForm={showImportForm}
|
|
onClick={() => {
|
|
setShowImportForm(true)
|
|
setError(null)
|
|
}}
|
|
/>
|
|
|
|
<KeyManagementImportForm
|
|
accountExists={accountExists}
|
|
showImportForm={showImportForm}
|
|
showReplaceWarning={showReplaceWarning}
|
|
importing={importing}
|
|
importKey={importKey}
|
|
onChangeImportKey={(value) => {
|
|
setImportKey(value)
|
|
setError(null)
|
|
}}
|
|
onCancel={() => {
|
|
setShowImportForm(false)
|
|
setImportKey('')
|
|
setError(null)
|
|
}}
|
|
onImport={() => {
|
|
void handleImport()
|
|
}}
|
|
onDismissReplaceWarning={() => {
|
|
setShowReplaceWarning(false)
|
|
}}
|
|
onConfirmReplace={() => {
|
|
void performImport(extractKeyFromUrl(importKey.trim()) ?? importKey.trim())
|
|
}}
|
|
/>
|
|
|
|
{/* Recovery Phrase Display (after import) */}
|
|
<KeyManagementRecoveryPanel
|
|
recoveryPhrase={recoveryPhrase}
|
|
newNpub={newNpub}
|
|
copiedRecoveryPhrase={copiedRecoveryPhrase}
|
|
onCopyRecoveryPhrase={handleCopyRecoveryPhrase}
|
|
onDone={() => {
|
|
setRecoveryPhrase(null)
|
|
setNewNpub(null)
|
|
void loadKeys()
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementErrorBanner(params: { error: string | null }): React.ReactElement | null {
|
|
if (!params.error) {
|
|
return null
|
|
}
|
|
return (
|
|
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
|
|
<p className="text-red-400">{params.error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementPublicKeysPanel(params: {
|
|
publicKeys: PublicKeys | null
|
|
copiedNpub: boolean
|
|
copiedPublicKey: boolean
|
|
onCopyNpub: () => Promise<void>
|
|
onCopyPublicKey: () => Promise<void>
|
|
}): React.ReactElement | null {
|
|
if (!params.publicKeys) {
|
|
return null
|
|
}
|
|
return (
|
|
<div className="space-y-4 mb-6">
|
|
<KeyManagementKeyCard
|
|
label={t('settings.keyManagement.publicKey.npub')}
|
|
value={params.publicKeys.npub}
|
|
copied={params.copiedNpub}
|
|
onCopy={params.onCopyNpub}
|
|
/>
|
|
<KeyManagementKeyCard
|
|
label={t('settings.keyManagement.publicKey.hex')}
|
|
value={params.publicKeys.publicKey}
|
|
copied={params.copiedPublicKey}
|
|
onCopy={params.onCopyPublicKey}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementKeyCard(params: {
|
|
label: string
|
|
value: string
|
|
copied: boolean
|
|
onCopy: () => Promise<void>
|
|
}): React.ReactElement {
|
|
return (
|
|
<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">{params.label}</p>
|
|
<button
|
|
onClick={() => {
|
|
void params.onCopy()
|
|
}}
|
|
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"
|
|
>
|
|
{params.copied ? t('settings.keyManagement.copied') : t('settings.keyManagement.copy')}
|
|
</button>
|
|
</div>
|
|
<p className="text-neon-cyan text-sm font-mono break-all">{params.value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementNoAccountBanner(params: {
|
|
publicKeys: PublicKeys | null
|
|
accountExists: boolean
|
|
}): React.ReactElement | null {
|
|
if (params.publicKeys || params.accountExists) {
|
|
return null
|
|
}
|
|
return (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
function KeyManagementImportButton(params: {
|
|
accountExists: boolean
|
|
showImportForm: boolean
|
|
onClick: () => void
|
|
}): React.ReactElement | null {
|
|
if (params.showImportForm) {
|
|
return null
|
|
}
|
|
return (
|
|
<button
|
|
onClick={params.onClick}
|
|
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"
|
|
>
|
|
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function KeyManagementImportForm(params: {
|
|
accountExists: boolean
|
|
showImportForm: boolean
|
|
showReplaceWarning: boolean
|
|
importing: boolean
|
|
importKey: string
|
|
onChangeImportKey: (value: string) => void
|
|
onCancel: () => void
|
|
onImport: () => void
|
|
onDismissReplaceWarning: () => void
|
|
onConfirmReplace: () => void
|
|
}): React.ReactElement | null {
|
|
if (!params.showImportForm) {
|
|
return null
|
|
}
|
|
return (
|
|
<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') }} />
|
|
{params.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={params.importKey}
|
|
onChange={(e) => {
|
|
params.onChangeImportKey(e.target.value)
|
|
}}
|
|
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>
|
|
|
|
<KeyManagementReplaceWarning
|
|
show={params.showReplaceWarning}
|
|
importing={params.importing}
|
|
onCancel={params.onDismissReplaceWarning}
|
|
onConfirm={params.onConfirmReplace}
|
|
/>
|
|
|
|
<KeyManagementImportFormActions
|
|
show={!params.showReplaceWarning}
|
|
importing={params.importing}
|
|
onCancel={params.onCancel}
|
|
onImport={params.onImport}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementReplaceWarning(params: {
|
|
show: boolean
|
|
importing: boolean
|
|
onCancel: () => void
|
|
onConfirm: () => void
|
|
}): React.ReactElement | null {
|
|
if (!params.show) {
|
|
return null
|
|
}
|
|
return (
|
|
<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={params.onCancel}
|
|
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={params.onConfirm}
|
|
disabled={params.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"
|
|
>
|
|
{params.importing ? t('settings.keyManagement.replace.replacing') : t('settings.keyManagement.replace.confirm')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementImportFormActions(params: {
|
|
show: boolean
|
|
importing: boolean
|
|
onCancel: () => void
|
|
onImport: () => void
|
|
}): React.ReactElement | null {
|
|
if (!params.show) {
|
|
return null
|
|
}
|
|
return (
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={params.onCancel}
|
|
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={params.onImport}
|
|
disabled={params.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"
|
|
>
|
|
{params.importing ? t('settings.keyManagement.import.importing') : t('settings.keyManagement.import.import')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyManagementRecoveryPanel(params: {
|
|
recoveryPhrase: string[] | null
|
|
newNpub: string | null
|
|
copiedRecoveryPhrase: boolean
|
|
onCopyRecoveryPhrase: () => Promise<void>
|
|
onDone: () => void
|
|
}): React.ReactElement | null {
|
|
if (!params.recoveryPhrase || !params.newNpub) {
|
|
return null
|
|
}
|
|
return (
|
|
<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">
|
|
{params.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 params.onCopyRecoveryPhrase()
|
|
}}
|
|
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"
|
|
>
|
|
{params.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">{params.newNpub}</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={params.onDone}
|
|
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>
|
|
)
|
|
}
|