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';
|
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 the stash as a file, optionally password- or key-protected. */
|
||||||
export function StashBackupExportDialog({
|
export function StashBackupExportDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
itemCount,
|
itemCount,
|
||||||
|
baseName,
|
||||||
canUseKey,
|
canUseKey,
|
||||||
onExport,
|
onExport,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
/** Filename without extension, e.g. `skatespots-stash-2026-10-03`. */
|
||||||
|
baseName: string;
|
||||||
canUseKey: boolean;
|
canUseKey: boolean;
|
||||||
onExport: (opts: { mode: ExportMode; password?: string }) => Promise<void>;
|
onExport: (opts: { mode: ExportMode; password?: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -32,6 +55,12 @@ export function StashBackupExportDialog({
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMode('password');
|
setMode('password');
|
||||||
|
|
@ -133,6 +162,19 @@ export function StashBackupExportDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] leading-snug text-gray-500">{t('walletUi.stash.passwordHint')}</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-400">{t('walletUi.stash.keyModeHint')}</p>
|
<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}
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-center font-mono text-[10px] text-gray-500">
|
||||||
|
{t('walletUi.stash.filenameWillSave', { name: previewName })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||||
|
|
|
||||||
|
|
@ -6324,6 +6324,12 @@ walletUi: {
|
||||||
unlockFailed: 'Could not unlock the backup.',
|
unlockFailed: 'Could not unlock the backup.',
|
||||||
show: 'Show',
|
show: 'Show',
|
||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
|
strength: 'Password strength',
|
||||||
|
strengthWeak: 'Weak',
|
||||||
|
strengthFair: 'Fair',
|
||||||
|
strengthGood: 'Good',
|
||||||
|
strengthStrong: 'Strong',
|
||||||
|
filenameWillSave: 'Will save as {{name}}',
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
import: 'Import',
|
import: 'Import',
|
||||||
exported: 'Stash exported',
|
exported: 'Stash exported',
|
||||||
|
|
|
||||||
|
|
@ -6324,6 +6324,12 @@ walletUi: {
|
||||||
unlockFailed: 'No se pudo desbloquear la copia.',
|
unlockFailed: 'No se pudo desbloquear la copia.',
|
||||||
show: 'Mostrar',
|
show: 'Mostrar',
|
||||||
hide: 'Ocultar',
|
hide: 'Ocultar',
|
||||||
|
strength: 'Seguridad de la contraseña',
|
||||||
|
strengthWeak: 'Débil',
|
||||||
|
strengthFair: 'Aceptable',
|
||||||
|
strengthGood: 'Buena',
|
||||||
|
strengthStrong: 'Fuerte',
|
||||||
|
filenameWillSave: 'Se guardará como {{name}}',
|
||||||
export: 'Exportar',
|
export: 'Exportar',
|
||||||
import: 'Importar',
|
import: 'Importar',
|
||||||
exported: 'Stash exportado',
|
exported: 'Stash exportado',
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,7 @@ export default function StashPage() {
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||||
const [importParsed, setImportParsed] = useState<unknown>(null);
|
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
|
// ── 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. ──
|
// 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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
|
|
@ -1567,6 +1568,7 @@ export default function StashPage() {
|
||||||
open={exportOpen}
|
open={exportOpen}
|
||||||
onOpenChange={setExportOpen}
|
onOpenChange={setExportOpen}
|
||||||
itemCount={items.length}
|
itemCount={items.length}
|
||||||
|
baseName={exportBaseName}
|
||||||
canUseKey={!!user?.signer?.nip44}
|
canUseKey={!!user?.signer?.nip44}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue