SkateSpots/src/components/auth/MnemonicSignupDialog.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~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).
2026-10-02 10:11:06 +07:00

449 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;