Make backup gently unavoidable, not skippable-forever (1.3.13)

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>
This commit is contained in:
shakespeare.diy 2026-09-09 11:23:44 +07:00
parent e3fb84d539
commit 4dc2b0129e
5 changed files with 52 additions and 26 deletions

View file

@ -13,17 +13,23 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
const BACKUP_REMINDER_KEY = 'backup-reminder-dismissed';
const BACKUP_CONFIRMED_KEY = 'backup-confirmed'; const BACKUP_CONFIRMED_KEY = 'backup-confirmed';
// Session-scoped snooze key. "Remind me later" used to set a PERMANENT
// localStorage flag, which silently turned into "never remind again" — a real
// safety hole. We now snooze only for the current browser session, so the
// reminder returns on the next visit until the user actually saves their login.
// Only `backup-confirmed` (set when they back up) stops it for good.
const BACKUP_SNOOZE_KEY = 'backup-reminder-snoozed-session';
/** /**
* Persistent backup reminder that shows after signup * Gently-persistent backup reminder shown after signup.
* Encourages users to backup their recovery phrase or nsec * Encourages users to save their recovery code (nsec) so they never lose access.
* Uses a bottom sheet for better mobile experience * Non-blocking: one tap to back up, one tap to say "already done", or snooze for
* this session. It keeps coming back each session until backup is confirmed.
* Uses a bottom sheet for a good experience on both phone and desktop.
*/ */
export function BackupReminder() { export function BackupReminder() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [dismissed, setDismissed] = useLocalStorage(BACKUP_REMINDER_KEY, false);
const [confirmed, setConfirmed] = useLocalStorage(BACKUP_CONFIRMED_KEY, false); const [confirmed, setConfirmed] = useLocalStorage(BACKUP_CONFIRMED_KEY, false);
const [showReminder, setShowReminder] = useState(false); const [showReminder, setShowReminder] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
@ -37,19 +43,25 @@ export function BackupReminder() {
// Show reminder if: // Show reminder if:
// 1. User is logged in // 1. User is logged in
// 2. Not dismissed // 2. Not snoozed for THIS session (resets when they come back)
// 3. Not confirmed they backed up // 3. Not confirmed they backed up (the only permanent stop)
// Add a small delay so it doesn't immediately pop up on login // Add a small delay so it doesn't immediately pop up on login.
const timer = setTimeout(() => { const timer = setTimeout(() => {
const shouldShow = !dismissed && !confirmed; const snoozed = sessionStorage.getItem(BACKUP_SNOOZE_KEY) === 'true';
const shouldShow = !snoozed && !confirmed;
setShowReminder(shouldShow); setShowReminder(shouldShow);
}, 2000); }, 2000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [user, dismissed, confirmed]); }, [user, confirmed]);
const handleDismiss = () => { const handleDismiss = () => {
setDismissed(true); // Snooze for this session only — it returns next visit until backup is done.
try {
sessionStorage.setItem(BACKUP_SNOOZE_KEY, 'true');
} catch {
// sessionStorage can throw in strict privacy modes — safe to ignore.
}
setShowReminder(false); setShowReminder(false);
}; };

View file

@ -40,6 +40,10 @@ export function SecuritySettings() {
const handleCopyRecovery = () => { const handleCopyRecovery = () => {
if (!recoveryCode) return; if (!recoveryCode) return;
navigator.clipboard.writeText(recoveryCode); 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.' }); toast({ title: 'Copied!', description: 'Recovery code copied. Store it somewhere safe.' });
}; };
@ -56,6 +60,10 @@ export function SecuritySettings() {
a.click(); a.click();
globalThis.URL.revokeObjectURL(url); globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a); 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.' }); toast({ title: 'Saved!', description: 'Recovery code downloaded. Keep it somewhere safe.' });
} catch { } catch {
toast({ title: 'Download failed', description: 'Please copy it manually instead.', variant: 'destructive' }); toast({ title: 'Download failed', description: 'Please copy it manually instead.', variant: 'destructive' });

View file

@ -13,6 +13,7 @@ import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { generateSecretKey, nip19 } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -44,6 +45,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const avatarFileInputRef = useRef<HTMLInputElement>(null); const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Shared source of truth: once the recovery code is saved here, mark backup
// confirmed so the gentle post-signup reminder never nags a safe user.
const [, setBackupConfirmed] = useLocalStorage('backup-confirmed', false);
// Silently generate a key and log the user in immediately. // Silently generate a key and log the user in immediately.
// No forced download, no crypto vocabulary — the account "just works". // No forced download, no crypto vocabulary — the account "just works".
@ -86,8 +90,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
globalThis.URL.revokeObjectURL(url); globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
// Mark as secured // Mark as secured — and record a confirmed backup so the reminder stops.
setKeySecured('downloaded'); setKeySecured('downloaded');
setBackupConfirmed(true);
toast({ toast({
title: 'Secret Key Saved!', title: 'Secret Key Saved!',
@ -561,6 +566,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10' className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
onClick={() => { onClick={() => {
navigator.clipboard.writeText(nsec); navigator.clipboard.writeText(nsec);
setBackupConfirmed(true);
toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' }); toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' });
}} }}
> >

View file

@ -92,13 +92,13 @@ const en = {
// Backup Reminder // Backup Reminder
backup: { backup: {
title: 'Backup Your Account!', title: 'Save your login',
subtitle: 'Important security reminder', subtitle: 'Takes 10 seconds — do it once',
message: 'Your recovery phrase or secret key (nsec) is the ONLY way to access your account from other devices.', message: 'Your recovery code is the only way back into your account. Save it and you can log in on any device.',
warning: 'If you lose it, you lose your account forever!', warning: "Lose it and there's no reset — no email, no password recovery.",
backupNow: 'Backup Now', backupNow: 'Save it now',
confirmed: "I've Backed It Up", confirmed: "I've saved it",
remindLater: 'Remind me later', remindLater: 'Later',
}, },
// PWA Install // PWA Install

View file

@ -93,13 +93,13 @@ const es = {
// Backup Reminder // Backup Reminder
backup: { backup: {
title: '¡Respalda Tu Cuenta!', title: 'Guarda tu acceso',
subtitle: 'Recordatorio de seguridad importante', subtitle: 'Toma 10 segundos — hazlo una vez',
message: 'Tu frase de recuperación o clave secreta (nsec) es la ÚNICA forma de acceder a tu cuenta desde otros dispositivos.', message: 'Tu código de recuperación es la única forma de volver a tu cuenta. Guárdalo y podrás iniciar sesión en cualquier dispositivo.',
warning: '¡Si la pierdes, pierdes tu cuenta para siempre!', warning: 'Si lo pierdes no hay reinicio — sin correo ni recuperación de contraseña.',
backupNow: 'Respaldar Ahora', backupNow: 'Guardar ahora',
confirmed: 'Ya la Respaldé', confirmed: 'Ya lo guardé',
remindLater: 'Recordarme después', remindLater: 'Después',
}, },
// PWA Install // PWA Install