Add recovery-code retrieval to Settings > Security (closes the deferred-backup loop)

Since signup now defers the key backup, users who skip it MUST be able to get
their recovery code later — otherwise we've just moved the trap. This adds a
"Your Recovery Code" card at the top of Settings > Security that:

- For nsec logins: reveals the recovery code on demand (hidden by default),
  with Copy, Download (.txt), and QR options, plus a Hide button and clear
  "never share this" warnings.
- For bunker / browser-extension / Amber logins: explains that SkateSpots
  never held the key and points the user to back it up in their signer app.

Also removed the old placeholder QR handler that only showed an error, and
updated the "Backup Not Confirmed" copy to direct users to the new reveal
card instead of falsely claiming lost keys are unrecoverable.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 11:10:51 +07:00
parent 54e6b4a809
commit f1cb2a32ba

View file

@ -1,18 +1,67 @@
import { useState } from 'react';
import { Download, Copy, Eye, EyeOff, Shield, AlertTriangle, CheckCircle, ExternalLink } from 'lucide-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);
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);
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">
@ -36,26 +85,121 @@ export function SecuritySettings() {
});
};
const handleGenerateQR = async () => {
try {
// Note: In a real implementation, you'd need access to the user's nsec/mnemonic
// This is a placeholder showing where QR generation would happen
toast({
title: 'QR Code Generation',
description: 'This feature requires accessing your stored keys. Please use the backup methods provided during signup.',
variant: 'destructive',
});
} catch {
toast({
title: 'QR Code Failed',
description: 'Could not generate QR code.',
variant: 'destructive',
});
}
};
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 === '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">
@ -97,8 +241,9 @@ export function SecuritySettings() {
{!confirmed && (
<CardContent>
<p className="text-sm text-gray-400 mb-4">
Did you save your recovery phrase or secret key during signup?
If not, you'll need to create a new account as we cannot recover lost keys.
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}