Closes the real safety hole: "Remind me later" used to set a PERMANENT localStorage flag, silently becoming "never remind again" — so users who never backed up were never reminded again. - BackupReminder: "Later" now snoozes for the current session only (sessionStorage), so it returns each visit until backup is actually done. Only `backup-confirmed` stops it for good. Never blocks the user. - Downloading OR copying the recovery code (in SignupDialog's done step and in SecuritySettings) now marks backup-confirmed — genuinely safe users stop being nagged automatically. - Plain-language copy pass (EN + ES): "Save your login" / "recovery code" instead of "nsec / recovery phrase" jargon. - Non-blocking and identical UX on both phone and desktop (bottom sheet). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
483 lines
21 KiB
TypeScript
483 lines
21 KiB
TypeScript
import { useState } from 'react';
|
||
import { Download, Copy, Eye, EyeOff, Shield, AlertTriangle, CheckCircle, ExternalLink, KeyRound, QrCode } from 'lucide-react';
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||
import { useNostrLogin } from '@nostrify/react/login';
|
||
import { toast } from '@/hooks/useToast';
|
||
|
||
const sanitizeFilename = (filename: string) => filename.replace(/[^a-z0-9_.-]/gi, '_');
|
||
|
||
export function SecuritySettings() {
|
||
const { user } = useCurrentUser();
|
||
const { logins } = useNostrLogin();
|
||
const [confirmed, setConfirmed] = useLocalStorage('backup-confirmed', false);
|
||
const [showWarning, setShowWarning] = useState(!confirmed);
|
||
|
||
// Recovery reveal state
|
||
const [revealed, setRevealed] = useState(false);
|
||
const [showQr, setShowQr] = useState(false);
|
||
|
||
// The active login. For nsec logins, the recovery code (nsec) is stored on
|
||
// the login object itself — this is how we can reveal it later for users who
|
||
// skipped backup at signup. For bunker/extension/Amber logins, the app never
|
||
// held the key, so there is nothing to reveal here.
|
||
const activeLogin = logins[0] as
|
||
| (typeof logins[number] & { nsec?: string; data?: { nsec?: string } })
|
||
| undefined;
|
||
const loginType = activeLogin?.type;
|
||
// Nostrify stores the bech32 nsec directly on the login (older builds nested
|
||
// it under `data`), so check both to be safe.
|
||
const recoveryCode = loginType === 'nsec'
|
||
? (activeLogin?.nsec ?? activeLogin?.data?.nsec)
|
||
: undefined;
|
||
|
||
const handleReveal = () => setRevealed(true);
|
||
|
||
const handleCopyRecovery = () => {
|
||
if (!recoveryCode) return;
|
||
navigator.clipboard.writeText(recoveryCode);
|
||
// Copying the recovery code counts as a real backup — mark confirmed so the
|
||
// gentle reminder stops nagging a user who has genuinely saved their login.
|
||
setConfirmed(true);
|
||
setShowWarning(false);
|
||
toast({ title: 'Copied!', description: 'Recovery code copied. Store it somewhere safe.' });
|
||
};
|
||
|
||
const handleDownloadRecovery = () => {
|
||
if (!recoveryCode) return;
|
||
try {
|
||
const blob = new Blob([recoveryCode], { type: 'text/plain; charset=utf-8' });
|
||
const url = globalThis.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = sanitizeFilename('skatespots-recovery-code.txt');
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
globalThis.URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
// A downloaded recovery file is a genuine backup — mark confirmed so the
|
||
// reminder stops for good.
|
||
setConfirmed(true);
|
||
setShowWarning(false);
|
||
toast({ title: 'Saved!', description: 'Recovery code downloaded. Keep it somewhere safe.' });
|
||
} catch {
|
||
toast({ title: 'Download failed', description: 'Please copy it manually instead.', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
if (!user) {
|
||
return (
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-12 text-center">
|
||
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||
<h3 className="text-lg font-medium mb-2">Login Required</h3>
|
||
<p className="text-muted-foreground">
|
||
Log in to access security settings.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const handleConfirmBackup = () => {
|
||
setConfirmed(true);
|
||
setShowWarning(false);
|
||
toast({
|
||
title: 'Backup Confirmed! ✅',
|
||
description: 'Great! Your keys are safe.',
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Recover / reveal recovery code — the real backup path for skaters who
|
||
skipped it at signup. Only possible for nsec logins. */}
|
||
<Card className="bg-black/90 border-2 border-amber-500/40">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-amber-400">
|
||
<KeyRound className="h-5 w-5" />
|
||
Your Recovery Code
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Didn't save it when you signed up? You can grab it here anytime.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{recoveryCode ? (
|
||
<>
|
||
<p className="text-sm text-gray-300">
|
||
This code is the <strong className="text-white">only</strong> way to log back in on a
|
||
new device or browser. Save it somewhere safe (a password manager, or written on paper).
|
||
Anyone who has it controls your account — never share it.
|
||
</p>
|
||
|
||
{!revealed ? (
|
||
<Button
|
||
onClick={handleReveal}
|
||
className="w-full bg-amber-500 hover:bg-amber-600 text-black font-bold"
|
||
>
|
||
<Eye className="h-4 w-4 mr-2" />
|
||
Reveal my recovery code
|
||
</Button>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div className="p-3 bg-black rounded-lg border border-amber-500/30 font-mono text-xs break-all text-white select-all">
|
||
{recoveryCode}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
||
onClick={handleCopyRecovery}
|
||
>
|
||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||
Copy
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
||
onClick={handleDownloadRecovery}
|
||
>
|
||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||
Save
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
||
onClick={() => setShowQr((v) => !v)}
|
||
>
|
||
<QrCode className="h-3.5 w-3.5 mr-1.5" />
|
||
QR
|
||
</Button>
|
||
</div>
|
||
|
||
{showQr && (
|
||
<div className="flex flex-col items-center gap-2 pt-2">
|
||
<div className="bg-white p-3 rounded-xl">
|
||
<QRCodeCanvas value={recoveryCode} size={200} level="M" />
|
||
</div>
|
||
<p className="text-xs text-gray-500 text-center max-w-xs">
|
||
Scan with another Nostr app to log in. Keep this screen private.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-amber-300/80">⚠️ Never share this. Hide it when you're done.</p>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-gray-500 hover:text-gray-300 h-auto py-1"
|
||
onClick={() => { setRevealed(false); setShowQr(false); }}
|
||
>
|
||
<EyeOff className="h-3.5 w-3.5 mr-1.5" />
|
||
Hide
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<Alert className="bg-blue-950/30 border border-blue-500/30">
|
||
<Shield className="h-4 w-4 text-blue-400" />
|
||
<AlertDescription className="text-sm text-gray-300">
|
||
{loginType === 'x-amber' && (
|
||
<>You're logged in with <strong className="text-white">Amber</strong>. Your recovery code lives inside Amber — open the Amber app to back it up. SkateSpots never sees your key.</>
|
||
)}
|
||
{loginType === 'bunker' && (
|
||
<>You're logged in with a <strong className="text-white">remote signer (bunker)</strong>. Your recovery code is held by your signer service, not SkateSpots. Back it up there.</>
|
||
)}
|
||
{(loginType as string) === 'extension' && (
|
||
<>You're logged in with a <strong className="text-white">browser extension</strong>. Your recovery code lives in the extension — use its own backup/export option. SkateSpots never sees your key.</>
|
||
)}
|
||
{!loginType && (
|
||
<>Your recovery code is managed by your signer app, not SkateSpots. Back it up there.</>
|
||
)}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Backup Warning */}
|
||
{showWarning && (
|
||
<Alert className="bg-gradient-to-r from-amber-500/10 to-red-500/10 border-2 border-amber-500/50">
|
||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||
<AlertDescription>
|
||
<div className="space-y-3">
|
||
<p className="font-bold text-amber-400">
|
||
⚠️ Have you backed up your recovery phrase or secret key?
|
||
</p>
|
||
<p className="text-sm text-gray-300">
|
||
Your keys are the ONLY way to access your account. If you lose them, you lose everything -
|
||
there's no password reset!
|
||
</p>
|
||
<Button
|
||
onClick={handleConfirmBackup}
|
||
size="sm"
|
||
className="bg-amber-500 hover:bg-amber-600 text-black font-bold"
|
||
>
|
||
<CheckCircle className="h-4 w-4 mr-2" />
|
||
I've Backed Up My Keys
|
||
</Button>
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* Backup Status */}
|
||
<Card className={confirmed ? "bg-green-950/20 border-2 border-green-500/30" : "bg-red-950/20 border-2 border-red-500/30"}>
|
||
<CardHeader>
|
||
<CardTitle className={confirmed ? "text-green-400" : "text-red-400"}>
|
||
{confirmed ? '✅ Backup Confirmed' : '⚠️ Backup Not Confirmed'}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{confirmed
|
||
? 'You\'ve confirmed that your keys are safely backed up.'
|
||
: 'Make sure to backup your recovery phrase or secret key!'}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
{!confirmed && (
|
||
<CardContent>
|
||
<p className="text-sm text-gray-400 mb-4">
|
||
Haven't saved your recovery code yet? No problem — grab it from the
|
||
<strong className="text-amber-400"> Your Recovery Code</strong> section above, store it
|
||
somewhere safe, then confirm below.
|
||
</p>
|
||
<Button
|
||
onClick={handleConfirmBackup}
|
||
variant="outline"
|
||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||
>
|
||
Yes, I've Backed Up My Keys
|
||
</Button>
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Security Information */}
|
||
<Card className="bg-black/90 border-2 border-[#ccff00]/30">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-[#ccff00]">
|
||
<Shield className="h-5 w-5" />
|
||
Key Security Best Practices
|
||
</CardTitle>
|
||
<CardDescription>
|
||
How to keep your Nostr account safe
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-3 text-sm">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">✍️</div>
|
||
<div>
|
||
<p className="font-bold text-white">Write it down on paper</p>
|
||
<p className="text-gray-400">Physical backup is the safest. Store in a secure location like a safe.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">💾</div>
|
||
<div>
|
||
<p className="font-bold text-white">Multiple backups</p>
|
||
<p className="text-gray-400">Keep copies in different secure locations (home safe, bank deposit box, etc.)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">🔒</div>
|
||
<div>
|
||
<p className="font-bold text-white">Never share your keys</p>
|
||
<p className="text-gray-400">Real Nostr apps NEVER ask for your nsec or recovery phrase after initial login</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">🚫</div>
|
||
<div>
|
||
<p className="font-bold text-white">Don't store in cloud unencrypted</p>
|
||
<p className="text-gray-400">Avoid Google Docs, email drafts, or plain text cloud storage</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">🔐</div>
|
||
<div>
|
||
<p className="font-bold text-white">Consider using a browser extension</p>
|
||
<p className="text-gray-400">Alby, nos2x, or Flamingo keep your keys secure and never expose them to websites</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-4 border-t border-[#ccff00]/20">
|
||
<h4 className="font-bold text-[#ff0040] mb-2 uppercase text-sm">
|
||
⚠️ Important Reminders
|
||
</h4>
|
||
<ul className="text-xs text-gray-400 space-y-1">
|
||
<li>• If you lose your keys, you lose your account permanently</li>
|
||
<li>• No company or person can reset your password - not even us</li>
|
||
<li>• Anyone with your keys has full control of your account</li>
|
||
<li>• Your keys control all your Nostr data across ALL apps</li>
|
||
</ul>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Browser Extension & Mobile App Recommendations */}
|
||
<Card className="bg-black/90 border-2 border-purple-500/30">
|
||
<CardHeader>
|
||
<CardTitle className="text-purple-400">🔌 Recommended Nostr Signers</CardTitle>
|
||
<CardDescription>
|
||
Use a Nostr signer for better security and convenience
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{/* Amber - Android */}
|
||
<a
|
||
href="https://github.com/greenart7c3/Amber"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-4 rounded-lg bg-gradient-to-r from-[#ccff00]/10 to-green-500/10 border border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="font-bold text-white flex items-center gap-2">
|
||
Amber
|
||
<span className="text-xs px-2 py-0.5 bg-[#ccff00]/20 text-[#ccff00] rounded-full">Android</span>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Secure Nostr signer for Android. Keeps your keys safe on your phone and signs events without exposing them.
|
||
</p>
|
||
</div>
|
||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||
</div>
|
||
</a>
|
||
<a
|
||
href="https://getalby.com"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-4 rounded-lg bg-gradient-to-r from-amber-500/10 to-yellow-500/10 border border-amber-500/30 hover:border-amber-500/60 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="font-bold text-white flex items-center gap-2">
|
||
Alby
|
||
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">Recommended</span>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Full-featured extension with Lightning wallet, WebLN, and NWC support.
|
||
</p>
|
||
</div>
|
||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||
</div>
|
||
</a>
|
||
|
||
<a
|
||
href="https://www.getflamingo.org/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-4 rounded-lg bg-black/50 border border-purple-500/30 hover:border-purple-500/60 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="font-bold text-white">Flamingo</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Simple, clean interface. Focuses on Nostr signing without wallet features.
|
||
</p>
|
||
</div>
|
||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||
</div>
|
||
</a>
|
||
|
||
<a
|
||
href="https://github.com/fiatjaf/nos2x"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-4 rounded-lg bg-black/50 border border-purple-500/30 hover:border-purple-500/60 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="font-bold text-white">nos2x</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Minimal, lightweight extension for advanced users.
|
||
</p>
|
||
</div>
|
||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Nostr Connect (Bunkers) */}
|
||
<Card className="bg-black/90 border-2 border-blue-500/30">
|
||
<CardHeader>
|
||
<CardTitle className="text-blue-400">☁️ Nostr Connect (Remote Signers)</CardTitle>
|
||
<CardDescription>
|
||
Use a remote signer to access your account from any device
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-gray-400 mb-4">
|
||
Nostr Connect (also called "bunkers") lets you store your keys on a secure server and sign in from any device.
|
||
Your keys never leave the server, but you can use your account anywhere!
|
||
</p>
|
||
|
||
<div className="space-y-3">
|
||
<a
|
||
href="https://nsec.app"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-4 rounded-lg bg-gradient-to-r from-blue-500/10 to-cyan-500/10 border border-blue-500/30 hover:border-blue-500/60 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="font-bold text-white flex items-center gap-2">
|
||
nsec.app
|
||
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full">Popular</span>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Free, secure remote signer. Create bunker:// URI and paste when logging in.
|
||
</p>
|
||
</div>
|
||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||
</div>
|
||
</a>
|
||
|
||
<a
|
||
href="https://nostrum.social"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="block p-4 rounded-lg bg-black/50 border border-blue-500/30 hover:border-blue-500/60 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="font-bold text-white">Nostrum</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
Another remote signer option with mobile support.
|
||
</p>
|
||
</div>
|
||
<ExternalLink className="h-4 w-4 text-gray-500" />
|
||
</div>
|
||
</a>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 rounded-lg bg-blue-950/30 border border-blue-500/30 text-sm text-gray-300">
|
||
<p><strong className="text-blue-400">💡 How to use:</strong> Create an account on one of these services,
|
||
get your bunker:// connection string, then paste it in the "Nostr Connect" tab when logging in.</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|