~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { Download, Copy, CheckCircle, Eye, EyeOff, ArrowRight, RefreshCw, Shield, ExternalLink } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { toast } from '@/hooks/useToast';
|
||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { cn } from '@/lib/utils';
|
||
import { generateMnemonicPhrase, privateKeyFromMnemonic, mnemonicToWords } from '@/lib/nip06';
|
||
import { useTranslation } from '@/contexts/language';
|
||
// qrcode imported lazily in async function
|
||
|
||
interface MnemonicSignupDialogProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSwitchToNsec: () => void;
|
||
}
|
||
|
||
const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
onSwitchToNsec
|
||
}) => {
|
||
const { t } = useTranslation();
|
||
const [step, setStep] = useState<'generate' | 'verify' | 'complete'>('generate');
|
||
const [mnemonic, setMnemonic] = useState('');
|
||
const [words, setWords] = useState<string[]>([]);
|
||
const [showMnemonic, setShowMnemonic] = useState(true);
|
||
const [verifyIndices, setVerifyIndices] = useState<number[]>([]);
|
||
const [verifyInputs, setVerifyInputs] = useState<Record<number, string>>({});
|
||
const [, setIsVerified] = useState(false);
|
||
const login = useLoginActions();
|
||
|
||
// Generate new mnemonic
|
||
const handleGenerate = () => {
|
||
const newMnemonic = generateMnemonicPhrase(128); // 12 words
|
||
setMnemonic(newMnemonic);
|
||
setWords(mnemonicToWords(newMnemonic));
|
||
|
||
// Pick 3 random words for verification
|
||
const indices: number[] = [];
|
||
while (indices.length < 3) {
|
||
const rand = Math.floor(Math.random() * 12);
|
||
if (!indices.includes(rand)) indices.push(rand);
|
||
}
|
||
setVerifyIndices(indices.sort((a, b) => a - b));
|
||
|
||
toast({
|
||
title: t('settingsUi.mnemonic.generatedTitle'),
|
||
description: t('settingsUi.mnemonic.generatedDesc'),
|
||
});
|
||
};
|
||
|
||
// Download mnemonic as text file
|
||
const handleDownload = () => {
|
||
try {
|
||
const blob = new Blob([mnemonic], { type: 'text/plain; charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'nostr-recovery-phrase.txt';
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
|
||
toast({
|
||
title: t('settingsUi.mnemonic.savedTitle'),
|
||
description: t('settingsUi.mnemonic.savedDesc'),
|
||
});
|
||
} catch {
|
||
toast({
|
||
title: t('settingsUi.mnemonic.downloadFailedTitle'),
|
||
description: t('settingsUi.mnemonic.downloadFailedDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
// Copy to clipboard
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(mnemonic);
|
||
toast({
|
||
title: t('settingsUi.mnemonic.copiedTitle'),
|
||
description: t('settingsUi.mnemonic.copiedDesc'),
|
||
});
|
||
};
|
||
|
||
// Generate QR code
|
||
const handleQR = async () => {
|
||
try {
|
||
const { default: QRCode } = await import('qrcode');
|
||
const qrDataUrl = await QRCode.toDataURL(mnemonic, {
|
||
width: 300,
|
||
margin: 2,
|
||
});
|
||
|
||
// Create a new window with the QR code
|
||
const win = window.open('', '_blank');
|
||
if (win) {
|
||
win.document.write(`
|
||
<html>
|
||
<head>
|
||
<title>${t('settingsUi.mnemonic.qrWindowTitle')}</title>
|
||
<style>
|
||
body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
margin: 0;
|
||
font-family: system-ui, sans-serif;
|
||
background: #000;
|
||
color: #fff;
|
||
}
|
||
h1 { margin-bottom: 1rem; }
|
||
img { border: 8px solid #fff; border-radius: 8px; }
|
||
p { margin-top: 1rem; color: #ff0040; font-weight: bold; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>🔐 ${t('settingsUi.mnemonic.qrHeading')}</h1>
|
||
<img src="${qrDataUrl}" alt="${t('settingsUi.mnemonic.qrAlt')}" />
|
||
<p>⚠️ ${t('settingsUi.mnemonic.qrWarning')}</p>
|
||
<button onclick="window.print()" style="margin-top: 1rem; padding: 0.5rem 1rem; font-size: 16px; cursor: pointer;">
|
||
${t('settingsUi.mnemonic.qrPrint')}
|
||
</button>
|
||
</body>
|
||
</html>
|
||
`);
|
||
win.document.close();
|
||
}
|
||
} catch {
|
||
toast({
|
||
title: t('settingsUi.mnemonic.qrFailedTitle'),
|
||
description: t('settingsUi.mnemonic.qrFailedDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
// Proceed to verification
|
||
const handleProceedToVerify = () => {
|
||
setStep('verify');
|
||
};
|
||
|
||
// Verify the words
|
||
const handleVerify = () => {
|
||
const isCorrect = verifyIndices.every((idx) => {
|
||
return verifyInputs[idx]?.toLowerCase().trim() === words[idx];
|
||
});
|
||
|
||
if (isCorrect) {
|
||
setIsVerified(true);
|
||
// Log in with the mnemonic
|
||
try {
|
||
const privateKey = privateKeyFromMnemonic(mnemonic);
|
||
const nsec = nip19.nsecEncode(privateKey);
|
||
login.nsec(nsec);
|
||
setStep('complete');
|
||
|
||
toast({
|
||
title: t('settingsUi.mnemonic.accountCreatedTitle'),
|
||
description: t('settingsUi.mnemonic.accountCreatedDesc'),
|
||
});
|
||
|
||
// Mark signup as completed
|
||
localStorage.setItem('signup_completed', Date.now().toString());
|
||
|
||
setTimeout(() => {
|
||
onClose();
|
||
}, 2000);
|
||
} catch {
|
||
toast({
|
||
title: t('settingsUi.mnemonic.loginFailedTitle'),
|
||
description: t('settingsUi.mnemonic.loginFailedDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
} else {
|
||
toast({
|
||
title: t('settingsUi.mnemonic.verifyFailedTitle'),
|
||
description: t('settingsUi.mnemonic.verifyFailedDesc'),
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
// Reset and start over
|
||
const handleStartOver = () => {
|
||
setMnemonic('');
|
||
setWords([]);
|
||
setVerifyInputs({});
|
||
setIsVerified(false);
|
||
setStep('generate');
|
||
};
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-2xl font-black uppercase text-center">
|
||
<span className="neon-orange">{t('settingsUi.mnemonic.titlePrefix')}</span>{' '}
|
||
<span className="neon-lime">{t('settingsUi.mnemonic.titleSuffix')}</span>
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{/* STEP 1: Generate & Backup */}
|
||
{step === 'generate' && (
|
||
<div className="space-y-6">
|
||
{!mnemonic ? (
|
||
<Card className="bg-black/50 border-[#ccff00]/20">
|
||
<CardContent className="p-8 text-center space-y-4">
|
||
<div className="text-6xl mb-4">🔑</div>
|
||
<h3 className="text-xl font-bold text-[#ccff00]">
|
||
{t('settingsUi.mnemonic.twelveWordTitle')}
|
||
</h3>
|
||
<p className="text-gray-400 text-sm max-w-md mx-auto">
|
||
{t('settingsUi.mnemonic.twelveWordDesc')}
|
||
</p>
|
||
<Button
|
||
onClick={handleGenerate}
|
||
className="w-full max-w-xs bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 font-bold uppercase"
|
||
size="lg"
|
||
>
|
||
{t('settingsUi.mnemonic.generate')}
|
||
</Button>
|
||
<div className="pt-4">
|
||
<button
|
||
onClick={onSwitchToNsec}
|
||
className="text-xs text-gray-500 hover:text-gray-300 underline"
|
||
>
|
||
{t('settingsUi.mnemonic.useNsecInstead')}
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
{/* Recovery phrase display */}
|
||
<Card className="bg-black/50 border-[#ccff00]/30">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-bold text-[#ccff00] uppercase">{t('settingsUi.mnemonic.yourPhrase')}</h3>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowMnemonic(!showMnemonic)}
|
||
className="text-gray-400 hover:text-white"
|
||
>
|
||
{showMnemonic ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleStartOver}
|
||
className="text-gray-400 hover:text-white"
|
||
>
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||
{words.map((word, idx) => (
|
||
<div
|
||
key={idx}
|
||
className={cn(
|
||
"flex items-center gap-2 p-3 rounded-lg border-2",
|
||
"bg-gray-900 border-[#ccff00]/20"
|
||
)}
|
||
>
|
||
<span className="text-xs text-gray-500 font-mono">{idx + 1}.</span>
|
||
<span className="font-mono text-sm text-white">
|
||
{showMnemonic ? word : '••••••'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2 justify-center">
|
||
<Button
|
||
onClick={handleCopy}
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
>
|
||
<Copy className="h-4 w-4 mr-2" />
|
||
{t('common.copy')}
|
||
</Button>
|
||
<Button
|
||
onClick={handleDownload}
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
{t('settingsUi.mnemonic.download')}
|
||
</Button>
|
||
<Button
|
||
onClick={handleQR}
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
>
|
||
{t('settingsUi.mnemonic.qrCode')}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Warning */}
|
||
<Card className="bg-[#ff0040]/10 border-2 border-[#ff0040]/50">
|
||
<CardContent className="p-6">
|
||
<h3 className="font-bold text-[#ff0040] mb-3 uppercase flex items-center gap-2">
|
||
<span>⚠️</span> {t('settingsUi.mnemonic.criticalWarning')}
|
||
</h3>
|
||
<ul className="space-y-2 text-sm text-gray-300">
|
||
<li>✍️ <strong>{t('settingsUi.mnemonic.warnWriteStrong')}</strong> {t('settingsUi.mnemonic.warnWrite')}</li>
|
||
<li>🔒 <strong>{t('settingsUi.mnemonic.warnNeverShareStrong')}</strong> {t('settingsUi.mnemonic.warnNeverShare')}</li>
|
||
<li>💾 <strong>{t('settingsUi.mnemonic.warnBackupsStrong')}</strong> {t('settingsUi.mnemonic.warnBackups')}</li>
|
||
<li>❌ <strong>{t('settingsUi.mnemonic.warnAnyoneStrong')}</strong> {t('settingsUi.mnemonic.warnAnyone')}</li>
|
||
<li>🚫 <strong>{t('settingsUi.mnemonic.warnLoseStrong')}</strong> {t('settingsUi.mnemonic.warnLose')}</li>
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Continue button */}
|
||
<Button
|
||
onClick={handleProceedToVerify}
|
||
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
|
||
size="lg"
|
||
>
|
||
{t('settingsUi.mnemonic.savedSecurely')}
|
||
<ArrowRight className="ml-2 h-5 w-5" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* STEP 2: Verify */}
|
||
{step === 'verify' && (
|
||
<div className="space-y-6">
|
||
<Card className="bg-black/50 border-[#ccff00]/30">
|
||
<CardContent className="p-6">
|
||
<h3 className="font-bold text-[#ccff00] mb-4 uppercase text-center">
|
||
{t('settingsUi.mnemonic.verifyTitle')}
|
||
</h3>
|
||
<p className="text-gray-400 text-sm mb-6 text-center">
|
||
{t('settingsUi.mnemonic.verifyDesc')}
|
||
</p>
|
||
|
||
<div className="space-y-4 max-w-md mx-auto">
|
||
{verifyIndices.map((idx) => (
|
||
<div key={idx} className="space-y-2">
|
||
<label className="text-sm text-gray-300 font-mono">
|
||
{t('settingsUi.mnemonic.wordLabel', { n: idx + 1 })}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={verifyInputs[idx] || ''}
|
||
onChange={(e) => setVerifyInputs({ ...verifyInputs, [idx]: e.target.value })}
|
||
placeholder={t('settingsUi.mnemonic.wordPlaceholder', { n: idx + 1 })}
|
||
className="w-full px-4 py-3 rounded-lg bg-gray-900 border-2 border-[#ccff00]/20 text-white placeholder-gray-600 focus:border-[#ccff00]/50 focus:outline-none font-mono"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex gap-3">
|
||
<Button
|
||
onClick={() => setStep('generate')}
|
||
variant="outline"
|
||
className="flex-1 border-gray-600 text-gray-400 hover:bg-gray-800"
|
||
>
|
||
{t('settingsUi.mnemonic.goBack')}
|
||
</Button>
|
||
<Button
|
||
onClick={handleVerify}
|
||
disabled={!verifyIndices.every((idx) => verifyInputs[idx]?.trim())}
|
||
className="flex-1 bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
|
||
>
|
||
{t('settingsUi.mnemonic.verifyCreate')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* STEP 3: Complete */}
|
||
{step === 'complete' && (
|
||
<div className="py-6 space-y-6">
|
||
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/10 border-2 border-[#ccff00]/50">
|
||
<CardContent className="p-8 text-center space-y-4">
|
||
<div className="text-6xl mb-2">✅</div>
|
||
<h3 className="text-2xl font-black uppercase text-[#ccff00]">
|
||
{t('settingsUi.mnemonic.completeTitle')}
|
||
</h3>
|
||
<p className="text-gray-300">
|
||
{t('settingsUi.mnemonic.completeDesc')}
|
||
</p>
|
||
<CheckCircle className="h-12 w-12 mx-auto text-[#00ff00]" />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Next Steps - Signer Recommendation */}
|
||
<Card className="bg-blue-950/30 border-2 border-blue-500/30">
|
||
<CardContent className="p-6 space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-blue-500/20">
|
||
<Shield className="h-5 w-5 text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<h4 className="font-bold text-blue-400">{t('settingsUi.mnemonic.signerTitle')}</h4>
|
||
<p className="text-xs text-gray-400">{t('settingsUi.mnemonic.signerSubtitle')}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-sm text-gray-300">
|
||
{t('settingsUi.mnemonic.signerDesc')}
|
||
</p>
|
||
<Link to="/signers" onClick={onClose}>
|
||
<Button className="w-full bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 border border-blue-500/50">
|
||
<ExternalLink className="h-4 w-4 mr-2" />
|
||
{t('settingsUi.mnemonic.signerCta')}
|
||
</Button>
|
||
</Link>
|
||
<button
|
||
onClick={onClose}
|
||
className="w-full text-xs text-gray-500 hover:text-gray-400 py-2"
|
||
>
|
||
{t('settingsUi.mnemonic.skip')}
|
||
</button>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
export default MnemonicSignupDialog;
|