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:
parent
0b50ce684f
commit
7aeda11041
4 changed files with 61 additions and 1 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue