SkateSpots/src/components/SecuritySettings.tsx
SkateSpots 9189fc6584 fix(trust): tier 2 — moderation, labels & data integrity (H2–H17)
- H2 useAdminModeration now fetches the 'cleared' label too, so an admin Undo
  actually propagates (newest-verdict-wins) instead of only working on-device.
- H3 admin takedowns publish the NIP-32 'hidden' label (useAdminHide) instead of
  an author-only kind-5 relays ignore; the cross-app hide is real now.
- H5 the /stats outage banner works: queryCanonical reports relay failures via
  onError, so 'partial' can be true and zeros are never presented as truth.
- H6 useShops got its own cache namespace (was colliding with useSkateShops).
- H7 listing NIP-05 is verified against the user's key, not merely self-asserted.
- H8 NIP-22 spot comments/transfer now use uppercase root tags (A/K/P); readers
  accept legacy #a; NIP.md corrected.
- H9 Lab profile edits preserve foreign  spot refs (crew link = 35006 only).
- H10 collection edits preserve foreign NIP-51 tags.
- H11 LAB_KINDS = all 13 provider kinds (was 35001-35005).
- H16 skip-to-content is a button that focuses <main> (was href=#main, which
  HashRouter treated as a route → 404).
- H17 CustomizeNavSheet is translated; useLocalStorage re-reads on key change and
  keeps stateRef in sync cross-tab.
- Backup 'confirmed' flag is now per-account (backup-confirmed:<pubkey>).
2026-10-02 21:30:01 +07:00

482 lines
20 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 { 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 { backupConfirmedKey } from '@/lib/backup';
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(backupConfirmedKey(user?.pubkey), 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>
);
}