feat(stash): password strength meter + filename hint on encrypted export

- Export dialog shows a 4-segment strength meter (length + character classes)
  with a Weak/Fair/Good/Strong label as you type the password.
- Shows the filename that will be saved; encrypted exports get an
  '-encrypted' suffix so the file is self-describing at a glance.
This commit is contained in:
SkateSpots 2026-10-03 11:29:04 +07:00
parent 0b50ce684f
commit 7aeda11041
4 changed files with 61 additions and 1 deletions

View file

@ -10,17 +10,40 @@ import { cn } from '@/lib/utils';
type ExportMode = 'password' | 'nip44' | 'plaintext';
/** Cheap password-strength heuristic (length + character classes), 0–4. */
function passwordStrength(pw: string): number {
if (!pw) return 0;
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/\d/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
return Math.min(4, score);
}
const STRENGTH_STEPS = [
{ key: 'walletUi.stash.strengthWeak', bar: 'bg-red-500', text: 'text-red-400' },
{ key: 'walletUi.stash.strengthWeak', bar: 'bg-red-500', text: 'text-red-400' },
{ key: 'walletUi.stash.strengthFair', bar: 'bg-amber-500', text: 'text-amber-400' },
{ key: 'walletUi.stash.strengthGood', bar: 'bg-lime-500', text: 'text-lime-400' },
{ key: 'walletUi.stash.strengthStrong', bar: 'bg-green-500', text: 'text-green-400' },
] as const;
/** Export the stash as a file, optionally password- or key-protected. */
export function StashBackupExportDialog({
open,
onOpenChange,
itemCount,
baseName,
canUseKey,
onExport,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
itemCount: number;
/** Filename without extension, e.g. `skatespots-stash-2026-10-03`. */
baseName: string;
canUseKey: boolean;
onExport: (opts: { mode: ExportMode; password?: string }) => Promise<void>;
}) {
@ -32,6 +55,12 @@ export function StashBackupExportDialog({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const strength = passwordStrength(pw);
const step = STRENGTH_STEPS[strength];
// The main button always produces an encrypted file; the plaintext link is a
// separate, explicit action.
const previewName = `${baseName}-encrypted.json`;
useEffect(() => {
if (open) {
setMode('password');
@ -133,6 +162,19 @@ export function StashBackupExportDialog({
/>
</div>
<p className="text-[11px] leading-snug text-gray-500">{t('walletUi.stash.passwordHint')}</p>
{pw.length > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-[10px] uppercase tracking-wide">
<span className="text-gray-500">{t('walletUi.stash.strength')}</span>
<span className={step.text}>{t(step.key)}</span>
</div>
<div className="flex gap-1">
{[0, 1, 2, 3].map((i) => (
<span key={i} className={cn('h-1 flex-1 rounded-full', i < strength ? step.bar : 'bg-white/10')} />
))}
</div>
</div>
)}
</div>
) : (
<p className="text-xs text-gray-400">{t('walletUi.stash.keyModeHint')}</p>
@ -143,6 +185,10 @@ export function StashBackupExportDialog({
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
</p>
)}
<p className="text-center font-mono text-[10px] text-gray-500">
{t('walletUi.stash.filenameWillSave', { name: previewName })}
</p>
</div>
<DialogFooter className="flex-col gap-2 sm:flex-col">

View file

@ -6324,6 +6324,12 @@ walletUi: {
unlockFailed: 'Could not unlock the backup.',
show: 'Show',
hide: 'Hide',
strength: 'Password strength',
strengthWeak: 'Weak',
strengthFair: 'Fair',
strengthGood: 'Good',
strengthStrong: 'Strong',
filenameWillSave: 'Will save as {{name}}',
export: 'Export',
import: 'Import',
exported: 'Stash exported',

View file

@ -6324,6 +6324,12 @@ walletUi: {
unlockFailed: 'No se pudo desbloquear la copia.',
show: 'Mostrar',
hide: 'Ocultar',
strength: 'Seguridad de la contraseña',
strengthWeak: 'Débil',
strengthFair: 'Aceptable',
strengthGood: 'Buena',
strengthStrong: 'Fuerte',
filenameWillSave: 'Se guardará como {{name}}',
export: 'Exportar',
import: 'Importar',
exported: 'Stash exportado',

View file

@ -662,6 +662,7 @@ export default function StashPage() {
const [exportOpen, setExportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
const [importParsed, setImportParsed] = useState<unknown>(null);
const exportBaseName = `skatespots-stash-${new Date().toISOString().slice(0, 10)}`;
// ── Backup & transfer: your stash is encrypted on Nostr, but a local copy you
// control means a dead relay (or a new account) can't lose your gear list. ──
@ -677,7 +678,7 @@ export default function StashPage() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skatespots-stash-${new Date().toISOString().slice(0, 10)}.json`;
a.download = `${exportBaseName}${mode === 'plaintext' ? '' : '-encrypted'}.json`;
document.body.appendChild(a);
a.click();
a.remove();
@ -1567,6 +1568,7 @@ export default function StashPage() {
open={exportOpen}
onOpenChange={setExportOpen}
itemCount={items.length}
baseName={exportBaseName}
canUseKey={!!user?.signer?.nip44}
onExport={handleExport}
/>