~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).
481 lines
20 KiB
TypeScript
481 lines
20 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';
|
||
import { useTranslation } from '@/contexts/language';
|
||
|
||
const sanitizeFilename = (filename: string) => filename.replace(/[^a-z0-9_.-]/gi, '_');
|
||
|
||
export function SecuritySettings() {
|
||
const { user } = useCurrentUser();
|
||
const { logins } = useNostrLogin();
|
||
const { t } = useTranslation();
|
||
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: t('common.copied'), description: t('settingsUi.security.recoveryCopied') });
|
||
};
|
||
|
||
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: t('settingsUi.security.savedTitle'), description: t('settingsUi.security.recoveryDownloaded') });
|
||
} catch {
|
||
toast({ title: t('settingsUi.security.downloadFailedTitle'), description: t('settingsUi.security.downloadFailedDesc'), 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">{t('settingsUi.security.loginRequired')}</h3>
|
||
<p className="text-muted-foreground">
|
||
{t('settingsUi.security.loginRequiredDesc')}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const handleConfirmBackup = () => {
|
||
setConfirmed(true);
|
||
setShowWarning(false);
|
||
toast({
|
||
title: t('settingsUi.security.backupConfirmedTitle'),
|
||
description: t('settingsUi.security.backupConfirmedDesc'),
|
||
});
|
||
};
|
||
|
||
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" />
|
||
{t('settingsUi.security.yourRecoveryCode')}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t('settingsUi.security.recoveryCodeDesc')}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{recoveryCode ? (
|
||
<>
|
||
<p className="text-sm text-gray-300">
|
||
{t('settingsUi.security.recoveryCodeBefore')}{' '}
|
||
<strong className="text-white">{t('settingsUi.security.only')}</strong>{' '}
|
||
{t('settingsUi.security.recoveryCodeAfter')}
|
||
</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" />
|
||
{t('settingsUi.security.revealRecovery')}
|
||
</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" />
|
||
{t('common.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" />
|
||
{t('common.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">
|
||
{t('settingsUi.security.qrHint')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-amber-300/80">{t('settingsUi.security.neverShareHide')}</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" />
|
||
{t('settingsUi.security.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' && (
|
||
<>{t('settingsUi.security.amberNoteBefore')} <strong className="text-white">Amber</strong>{t('settingsUi.security.amberNoteAfter')}</>
|
||
)}
|
||
{loginType === 'bunker' && (
|
||
<>{t('settingsUi.security.bunkerNoteBefore')} <strong className="text-white">{t('settingsUi.security.remoteSigner')}</strong>{t('settingsUi.security.bunkerNoteAfter')}</>
|
||
)}
|
||
{(loginType as string) === 'extension' && (
|
||
<>{t('settingsUi.security.extensionNoteBefore')} <strong className="text-white">{t('settingsUi.security.browserExtension')}</strong>{t('settingsUi.security.extensionNoteAfter')}</>
|
||
)}
|
||
{!loginType && (
|
||
<>{t('settingsUi.security.otherNote')}</>
|
||
)}
|
||
</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">
|
||
{t('settingsUi.security.backupWarningTitle')}
|
||
</p>
|
||
<p className="text-sm text-gray-300">
|
||
{t('settingsUi.security.backupWarningBody')}
|
||
</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" />
|
||
{t('settingsUi.security.iveBackedUp')}
|
||
</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 ? t('settingsUi.security.backupConfirmed') : t('settingsUi.security.backupNotConfirmed')}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{confirmed
|
||
? t('settingsUi.security.backupConfirmedStatusDesc')
|
||
: t('settingsUi.security.backupNotConfirmedDesc')}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
{!confirmed && (
|
||
<CardContent>
|
||
<p className="text-sm text-gray-400 mb-4">
|
||
{t('settingsUi.security.notSavedYetBefore')}
|
||
<strong className="text-amber-400"> {t('settingsUi.security.yourRecoveryCode')}</strong> {t('settingsUi.security.notSavedYetAfter')}
|
||
</p>
|
||
<Button
|
||
onClick={handleConfirmBackup}
|
||
variant="outline"
|
||
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
|
||
>
|
||
{t('settingsUi.security.yesBackedUp')}
|
||
</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" />
|
||
{t('settingsUi.security.bestPracticesTitle')}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t('settingsUi.security.bestPracticesDesc')}
|
||
</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">{t('settingsUi.security.bp1Title')}</p>
|
||
<p className="text-gray-400">{t('settingsUi.security.bp1Desc')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">💾</div>
|
||
<div>
|
||
<p className="font-bold text-white">{t('settingsUi.security.bp2Title')}</p>
|
||
<p className="text-gray-400">{t('settingsUi.security.bp2Desc')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">🔒</div>
|
||
<div>
|
||
<p className="font-bold text-white">{t('settingsUi.security.bp3Title')}</p>
|
||
<p className="text-gray-400">{t('settingsUi.security.bp3Desc')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">🚫</div>
|
||
<div>
|
||
<p className="font-bold text-white">{t('settingsUi.security.bp4Title')}</p>
|
||
<p className="text-gray-400">{t('settingsUi.security.bp4Desc')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-2xl">🔐</div>
|
||
<div>
|
||
<p className="font-bold text-white">{t('settingsUi.security.bp5Title')}</p>
|
||
<p className="text-gray-400">{t('settingsUi.security.bp5Desc')}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-4 border-t border-[#ccff00]/20">
|
||
<h4 className="font-bold text-[#ff0040] mb-2 uppercase text-sm">
|
||
{t('settingsUi.security.importantReminders')}
|
||
</h4>
|
||
<ul className="text-xs text-gray-400 space-y-1">
|
||
<li>• {t('settingsUi.security.rem1')}</li>
|
||
<li>• {t('settingsUi.security.rem2')}</li>
|
||
<li>• {t('settingsUi.security.rem3')}</li>
|
||
<li>• {t('settingsUi.security.rem4')}</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">{t('settingsUi.security.recommendedSigners')}</CardTitle>
|
||
<CardDescription>
|
||
{t('settingsUi.security.recommendedSignersDesc')}
|
||
</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">
|
||
{t('settingsUi.security.amberDesc')}
|
||
</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">{t('settingsUi.security.recommended')}</span>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
{t('settingsUi.security.albyDesc')}
|
||
</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">
|
||
{t('settingsUi.security.flamingoDesc')}
|
||
</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">
|
||
{t('settingsUi.security.nos2xDesc')}
|
||
</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">{t('settingsUi.security.nostrConnectTitle')}</CardTitle>
|
||
<CardDescription>
|
||
{t('settingsUi.security.nostrConnectDesc')}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-gray-400 mb-4">
|
||
{t('settingsUi.security.nostrConnectBody')}
|
||
</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">{t('common.popular')}</span>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-1">
|
||
{t('settingsUi.security.nsecAppDesc')}
|
||
</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">
|
||
{t('settingsUi.security.nostrumDesc')}
|
||
</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">{t('settingsUi.security.howToUse')}</strong> {t('settingsUi.security.howToUseDesc')}</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|