feat(stash): password/key-encrypted backups for Export/Import

The stash export was plaintext JSON, so a leaked/shared file exposed the whole
gear list. Export now wraps the payload in a version-2 JSON envelope:

- password mode: scrypt (NIP-49 params, 2^16/r8/p1) -> AES-256-GCM, with the
  header (mode+cipher+KDF) bound as AAD so it can't be downgraded.
- nip44 mode: NIP-44-encrypted to your own key (no password to remember; needs
  the matching key to open).
- plaintext v1 exports still import unchanged.

New src/lib/stashBackup.ts (verified round-trip, wrong-password, tamper and
KDF-downgrade rejection) + StashBackupExportDialog/StashBackupUnlockDialog.
The import picker only prompts for a password on password-encrypted files.
Password mode needs no signer (Amber-friendly); 'My key' is hidden unless the
signer supports NIP-44.
This commit is contained in:
SkateSpots 2026-10-03 11:23:49 +07:00
parent 86c9f6f856
commit 0b50ce684f
6 changed files with 590 additions and 26 deletions

View file

@ -1324,7 +1324,15 @@ session with **no other memory of this project**. So:
decrypted list.** Also added **Export/Import** on `/stash` (`Backup &
transfer` card, desktop sidebar + mobile): export writes a JSON you control;
import appends in ONE re-encrypt, skipping title+category+size duplicates
(`importItems`). **UX pass (same date):** the filter bar gained a **Sort**
(`importItems`). **Encrypted backups (2026-10-03):** `src/lib/stashBackup.ts`
wraps the export in a **version-2 JSON envelope** — `password` mode (scrypt
NIP-49 params 2^16/r8/p1 → **AES-256-GCM**, header bound as AAD so KDF/
cipher can't be downgraded) or `nip44` mode (encrypted to your own key, no
password). Plaintext v1 files still import. `StashBackupExportDialog` +
`StashBackupUnlockDialog` (`components/StashBackupDialogs.tsx`); the picker
prompts for a password only for `password` files. ⚠️ Password mode needs no
signer (works with Amber); nip44 mode does. Verified round-trip / wrong
password / tamper / KDF-downgrade rejection in Node. **UX pass (same date):** the filter bar gained a **Sort**
(Newest / Value / Condition / A–Z) and search now includes **`size`**; a
**"Public stash"** card lists which items are public (with the profile link
for "see what others see"); and **bulk actions** — a header **Select** toggle

View file

@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import { Loader2, Download, KeyRound, Lock, Eye, EyeOff, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
type ExportMode = 'password' | 'nip44' | 'plaintext';
/** Export the stash as a file, optionally password- or key-protected. */
export function StashBackupExportDialog({
open,
onOpenChange,
itemCount,
canUseKey,
onExport,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
itemCount: number;
canUseKey: boolean;
onExport: (opts: { mode: ExportMode; password?: string }) => Promise<void>;
}) {
const { t } = useTranslation();
const [mode, setMode] = useState<'password' | 'nip44'>('password');
const [pw, setPw] = useState('');
const [pw2, setPw2] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setMode('password');
setPw('');
setPw2('');
setShow(false);
setBusy(false);
setError(null);
}
}, [open]);
const run = async (m: ExportMode) => {
if (m === 'password') {
if (pw.length < 8) return setError(t('walletUi.stash.pwTooShort'));
if (pw !== pw2) return setError(t('walletUi.stash.pwMismatch'));
}
setBusy(true);
setError(null);
try {
await onExport({ mode: m, password: m === 'password' ? pw : undefined });
onOpenChange(false);
} catch (e) {
setError(e instanceof Error ? e.message : t('walletUi.stash.exportFailed'));
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm bg-black border-white/10 text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="h-5 w-5 text-[#ff6600]" /> {t('walletUi.stash.exportTitle')}
</DialogTitle>
<DialogDescription className="text-gray-400">
{t('walletUi.stash.exportDialogDesc', { count: itemCount })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Mode toggle */}
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setMode('password')}
className={cn(
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors',
mode === 'password' ? 'border-[#ccff00] bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/10 text-gray-400 hover:text-white',
)}
>
<Lock className="h-3.5 w-3.5" /> {t('walletUi.stash.modePassword')}
</button>
<button
type="button"
disabled={!canUseKey}
onClick={() => canUseKey && setMode('nip44')}
className={cn(
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-bold uppercase tracking-wide transition-colors',
mode === 'nip44' ? 'border-[#ccff00] bg-[#ccff00]/10 text-[#ccff00]' : 'border-white/10 text-gray-400 hover:text-white',
!canUseKey && 'cursor-not-allowed opacity-40',
)}
>
<KeyRound className="h-3.5 w-3.5" /> {t('walletUi.stash.modeKey')}
</button>
</div>
{mode === 'password' ? (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="stash-pw" className="text-xs text-gray-400">{t('walletUi.stash.password')}</Label>
<div className="relative">
<Input
id="stash-pw"
type={show ? 'text' : 'password'}
value={pw}
onChange={(e) => setPw(e.target.value)}
autoComplete="new-password"
className="bg-black/50 border-white/20 pr-9"
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
aria-label={show ? t('walletUi.stash.hide') : t('walletUi.stash.show')}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="stash-pw2" className="text-xs text-gray-400">{t('walletUi.stash.confirmPassword')}</Label>
<Input
id="stash-pw2"
type={show ? 'text' : 'password'}
value={pw2}
onChange={(e) => setPw2(e.target.value)}
autoComplete="new-password"
className="bg-black/50 border-white/20"
/>
</div>
<p className="text-[11px] leading-snug text-gray-500">{t('walletUi.stash.passwordHint')}</p>
</div>
) : (
<p className="text-xs text-gray-400">{t('walletUi.stash.keyModeHint')}</p>
)}
{error && (
<p className="flex items-start gap-1.5 rounded-lg bg-destructive/10 p-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
</p>
)}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button
onClick={() => run(mode)}
disabled={busy}
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{t('walletUi.stash.export')}
</Button>
<button
type="button"
disabled={busy}
onClick={() => run('plaintext')}
className="text-center text-[11px] text-gray-500 underline hover:text-gray-300"
>
{t('walletUi.stash.exportPlaintext')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Prompt for the password of an encrypted backup on import. */
export function StashBackupUnlockDialog({
open,
onOpenChange,
onUnlock,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onUnlock: (password: string) => Promise<void>;
}) {
const { t } = useTranslation();
const [pw, setPw] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setPw('');
setShow(false);
setBusy(false);
setError(null);
}
}, [open]);
const submit = async () => {
setBusy(true);
setError(null);
try {
await onUnlock(pw);
onOpenChange(false);
} catch (e) {
setError(e instanceof Error ? e.message : t('walletUi.stash.unlockFailed'));
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm bg-black border-white/10 text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-[#ff6600]" /> {t('walletUi.stash.unlockTitle')}
</DialogTitle>
<DialogDescription className="text-gray-400">
{t('walletUi.stash.unlockDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="stash-unlock" className="text-xs text-gray-400">{t('walletUi.stash.password')}</Label>
<div className="relative">
<Input
id="stash-unlock"
type={show ? 'text' : 'password'}
value={pw}
onChange={(e) => setPw(e.target.value)}
autoComplete="current-password"
className="bg-black/50 border-white/20 pr-9"
onKeyDown={(e) => { if (e.key === 'Enter' && pw && !busy) void submit(); }}
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
aria-label={show ? t('walletUi.stash.hide') : t('walletUi.stash.show')}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{error && (
<p className="flex items-start gap-1.5 rounded-lg bg-destructive/10 p-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
</p>
)}
</div>
<DialogFooter>
<Button
onClick={submit}
disabled={busy || !pw}
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Lock className="h-4 w-4" />}
{t('walletUi.stash.unlock')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -6306,6 +6306,24 @@ walletUi: {
noMatchDesc: 'Try adjusting your filters.',
backupTransfer: 'Backup & transfer',
backupDesc: "Your stash is encrypted on Nostr. Keep a copy you control — a dead relay or a new account can't lose your gear list.",
exportTitle: 'Encrypted backup',
exportDialogDesc: 'Save {{count}} item(s) to a file. Encrypt it so a leaked file is not a leak.',
modePassword: 'Password',
modeKey: 'My key',
password: 'Password',
confirmPassword: 'Confirm password',
passwordHint: "Use at least 8 characters. There is no recovery — don't forget it.",
keyModeHint: 'Encrypted to your Nostr key. Only that key can open it — nothing to remember.',
exportPlaintext: 'Export without encryption',
pwTooShort: 'Use at least 8 characters.',
pwMismatch: "Passwords don't match.",
exportFailed: 'Could not create the backup.',
unlockTitle: 'Unlock backup',
unlockDesc: 'This backup is password-protected.',
unlock: 'Unlock',
unlockFailed: 'Could not unlock the backup.',
show: 'Show',
hide: 'Hide',
export: 'Export',
import: 'Import',
exported: 'Stash exported',

View file

@ -6306,6 +6306,24 @@ walletUi: {
noMatchDesc: 'Prueba a ajustar los filtros.',
backupTransfer: 'Copia y transferencia',
backupDesc: 'Tu stash está cifrado en Nostr. Guarda una copia que controles — así un relé caído o una cuenta nueva no te harán perder tu lista de material.',
exportTitle: 'Copia cifrada',
exportDialogDesc: 'Guarda {{count}} artículo(s) en un archivo. Cífralo para que un archivo filtrado no sea una filtración.',
modePassword: 'Contraseña',
modeKey: 'Mi clave',
password: 'Contraseña',
confirmPassword: 'Confirmar contraseña',
passwordHint: 'Usa al menos 8 caracteres. No hay recuperación: no la olvides.',
keyModeHint: 'Cifrado con tu clave de Nostr. Solo esa clave puede abrirlo: nada que recordar.',
exportPlaintext: 'Exportar sin cifrar',
pwTooShort: 'Usa al menos 8 caracteres.',
pwMismatch: 'Las contraseñas no coinciden.',
exportFailed: 'No se pudo crear la copia.',
unlockTitle: 'Desbloquear copia',
unlockDesc: 'Esta copia está protegida con contraseña.',
unlock: 'Desbloquear',
unlockFailed: 'No se pudo desbloquear la copia.',
show: 'Mostrar',
hide: 'Ocultar',
export: 'Exportar',
import: 'Importar',
exported: 'Stash exportado',

210
src/lib/stashBackup.ts Normal file
View file

@ -0,0 +1,210 @@
/**
* Stash backup encryption — password- or key-protected export envelopes.
*
* The stash on Nostr is NIP-44-encrypted to you, but the *export file* used to
* be plaintext JSON, so a leaked file exposed the whole gear list. This wraps the
* same payload so a lost file isn't a leak:
*
* - `password` mode: scrypt (NIP-49 parameters: N=2^16, r=8, p=1) → AES-256-GCM.
* Independent of your Nostr key, so it survives a lost key / new account.
* - `nip44` mode: NIP-44-encrypted to your own key — nothing to remember, but
* only decryptable with that same nsec.
*
* Both are versioned JSON envelopes. Plaintext v1 exports still import, so this
* is fully backward compatible.
*/
import { scrypt } from '@noble/hashes/scrypt.js';
export const STASH_EXPORT_VERSION = 2;
/** scrypt work factor — 2^16 rounds, the same cost the app uses for NIP-49. */
const LOG_N = 16;
const KDF = { algo: 'scrypt', logN: LOG_N, r: 8, p: 1 } as const;
const CIPHER = 'aes-256-gcm';
export type StashExportMode = 'plaintext' | 'password' | 'nip44';
interface PlaintextEnvelope {
app?: string;
type?: string;
version?: number;
exportedAt?: string;
items?: unknown;
}
interface PasswordEnvelope {
app: 'SkateSpots';
type: 'stash-export';
version: number;
encrypted: true;
mode: 'password';
kdf: { algo: 'scrypt'; logN: number; r: number; p: number; salt: string };
cipher: string;
iv: string;
ciphertext: string;
}
interface Nip44Envelope {
app: 'SkateSpots';
type: 'stash-export';
version: number;
encrypted: true;
mode: 'nip44';
/** The pubkey the backup is encrypted to (must equal the logged-in key). */
pubkey: string;
ciphertext: string;
}
export interface StashSigner {
nip44?: {
encrypt(peer: string, plaintext: string): Promise<string>;
decrypt(peer: string, ciphertext: string): Promise<string>;
};
}
// ── base64 <-> bytes (browser + Node/JSDOM all provide btoa/atob) ────────────
function toB64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
function fromB64(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
const utf8 = (s: string) => new TextEncoder().encode(s);
const fromUtf8 = (b: BufferSource) => new TextDecoder().decode(b);
/**
* Associated data binds the header (mode + KDF params) to the ciphertext, so a
* tampered `logN`/`cipher`/`mode` breaks the AEAD tag instead of silently
* downgrading security.
*/
type Kdf = { algo: string; logN: number; r: number; p: number };
function aad(mode: string, kdf: Kdf = KDF, cipher: string = CIPHER): Uint8Array {
return utf8(
`SkateSpots|stash-export|v${STASH_EXPORT_VERSION}|${mode}|${cipher}|${kdf.algo}|logN=${kdf.logN}|r=${kdf.r}|p=${kdf.p}`,
);
}
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const raw = scrypt(password.normalize('NFKC'), salt, { N: 2 ** LOG_N, r: 8, p: 1, dkLen: 32 });
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
/** Which mode a parsed file is in (`plaintext` for anything unrecognised). */
export function stashExportMode(parsed: unknown): StashExportMode {
const o = parsed as { encrypted?: unknown; mode?: unknown } | null;
if (!o || typeof o !== 'object' || o.encrypted !== true) return 'plaintext';
return o.mode === 'nip44' ? 'nip44' : 'password';
}
/** Build the standard plaintext payload (also what gets encrypted). */
export function buildStashPayload(items: unknown): PlaintextEnvelope {
return {
app: 'SkateSpots',
type: 'stash-export',
version: STASH_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
items,
};
}
export function createPlaintextExport(payload: unknown): string {
return JSON.stringify(payload, null, 2);
}
/** Encrypt a payload under a password → JSON envelope string. */
export async function createPasswordExport(payload: unknown, password: string): Promise<string> {
if (!password) throw new Error('A password is required.');
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: aad('password') },
key,
utf8(JSON.stringify(payload)),
),
);
const env: PasswordEnvelope = {
app: 'SkateSpots',
type: 'stash-export',
version: STASH_EXPORT_VERSION,
encrypted: true,
mode: 'password',
kdf: { ...KDF, salt: toB64(salt) },
cipher: CIPHER,
iv: toB64(iv),
ciphertext: toB64(ciphertext),
};
return JSON.stringify(env, null, 2);
}
/** Encrypt a payload to the user's own Nostr key (NIP-44) → JSON envelope string. */
export async function createNip44Export(
payload: unknown,
signer: StashSigner | undefined,
pubkey: string,
): Promise<string> {
if (!signer?.nip44) throw new Error('Your signer does not support NIP-44 encryption.');
const ciphertext = await signer.nip44.encrypt(pubkey, JSON.stringify(payload));
const env: Nip44Envelope = {
app: 'SkateSpots',
type: 'stash-export',
version: STASH_EXPORT_VERSION,
encrypted: true,
mode: 'nip44',
pubkey,
ciphertext,
};
return JSON.stringify(env, null, 2);
}
/**
* Decrypt a parsed export back to its payload. `plaintext` passes through.
* Throws a human-readable error on a wrong password / wrong key / tampering.
*/
export async function decryptStashExport(
parsed: unknown,
opts: { password?: string; signer?: StashSigner; pubkey?: string },
): Promise<unknown> {
const mode = stashExportMode(parsed);
if (mode === 'plaintext') return parsed;
if (mode === 'password') {
const env = parsed as PasswordEnvelope;
if (!opts.password) throw new Error('A password is required.');
const key = await deriveKey(opts.password, fromB64(env.kdf.salt));
try {
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: fromB64(env.iv), additionalData: aad('password', env.kdf, env.cipher) },
key,
fromB64(env.ciphertext),
);
return JSON.parse(fromUtf8(pt));
} catch {
throw new Error('Wrong password — or the file is damaged.');
}
}
// nip44
const env = parsed as Nip44Envelope;
if (!opts.signer?.nip44) {
throw new Error('Log in with the account this backup was made for.');
}
if (opts.pubkey && env.pubkey && opts.pubkey.toLowerCase() !== env.pubkey.toLowerCase()) {
throw new Error('This backup is encrypted to a different account.');
}
try {
const pt = await opts.signer.nip44.decrypt(env.pubkey, env.ciphertext);
return JSON.parse(pt);
} catch {
throw new Error('Could not decrypt — log in as the account that made this backup.');
}
}

View file

@ -26,6 +26,17 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useStashShowcaseSync } from '@/hooks/useStashShowcase';
import { StashBackupExportDialog, StashBackupUnlockDialog } from '@/components/StashBackupDialogs';
import {
buildStashPayload,
createPlaintextExport,
createPasswordExport,
createNip44Export,
decryptStashExport,
stashExportMode,
type StashExportMode,
type StashSigner,
} from '@/lib/stashBackup';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useUploadFile } from '@/hooks/useUploadFile';
@ -648,18 +659,21 @@ export default function StashPage() {
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
const [importParsed, setImportParsed] = useState<unknown>(null);
// ── 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. ──
const handleExport = () => {
const payload = {
app: 'SkateSpots',
type: 'stash-export',
version: 1,
exportedAt: new Date().toISOString(),
items,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const handleExport = async ({ mode, password }: { mode: StashExportMode; password?: string }) => {
const payload = buildStashPayload(items);
const text =
mode === 'plaintext'
? createPlaintextExport(payload)
: mode === 'nip44'
? await createNip44Export(payload, user?.signer, user?.pubkey ?? '')
: await createPasswordExport(payload, password ?? '');
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -671,24 +685,40 @@ export default function StashPage() {
toast({ title: t('walletUi.stash.exported'), description: t('walletUi.stash.exportedDesc', { count: items.length }) });
};
/** Decrypt (if needed) then merge an export into the stash. Throws on failure. */
const runImport = async (
parsed: unknown,
opts: { password?: string; signer?: StashSigner; pubkey?: string },
) => {
const payload = await decryptStashExport(parsed, opts);
const raw = Array.isArray(payload) ? payload : (payload as { items?: unknown })?.items;
if (!Array.isArray(raw)) throw new Error(t('walletUi.stash.noItemsInFile'));
const cleaned = raw.filter(
(i): i is Omit<StashItem, 'id' | 'addedAt'> =>
!!i &&
typeof i === 'object' &&
typeof (i as { title?: unknown }).title === 'string' &&
typeof (i as { category?: unknown }).category === 'string',
);
if (cleaned.length === 0) throw new Error(t('walletUi.stash.noValidItems'));
const res = await importItems.mutateAsync(cleaned);
toast({
title: t('walletUi.stash.imported', { count: res.added }),
description: res.skipped > 0 ? t('walletUi.stash.importSkipped', { count: res.skipped }) : t('walletUi.stash.importAdded'),
});
};
const handleImportFile = async (file: File) => {
try {
const parsed = JSON.parse(await file.text()) as unknown;
const raw = Array.isArray(parsed) ? parsed : (parsed as { items?: unknown })?.items;
if (!Array.isArray(raw)) throw new Error(t('walletUi.stash.noItemsInFile'));
const cleaned = raw.filter(
(i): i is Omit<StashItem, 'id' | 'addedAt'> =>
!!i &&
typeof i === 'object' &&
typeof (i as { title?: unknown }).title === 'string' &&
typeof (i as { category?: unknown }).category === 'string',
);
if (cleaned.length === 0) throw new Error(t('walletUi.stash.noValidItems'));
const res = await importItems.mutateAsync(cleaned);
toast({
title: t('walletUi.stash.imported', { count: res.added }),
description: res.skipped > 0 ? t('walletUi.stash.importSkipped', { count: res.skipped }) : t('walletUi.stash.importAdded'),
});
// Password-protected: prompt for it, then decrypt + import.
if (stashExportMode(parsed) === 'password') {
setImportParsed(parsed);
setUnlockOpen(true);
return;
}
// Plaintext or NIP-44 (needs the matching logged-in key).
await runImport(parsed, { signer: user?.signer, pubkey: user?.pubkey });
} catch (e) {
toast({
title: t('walletUi.stash.importFailed'),
@ -698,6 +728,12 @@ export default function StashPage() {
}
};
/** Called by the unlock dialog: throws (dialog shows the error) or resolves. */
const handleUnlock = async (password: string) => {
await runImport(importParsed, { password });
setImportParsed(null);
};
const backupCard = (
<div className="space-y-3 rounded-2xl border border-white/6 bg-white/2 p-4">
<div className="flex items-center gap-2">
@ -712,7 +748,7 @@ export default function StashPage() {
size="sm"
variant="outline"
className="flex-1 gap-1.5 border-white/15"
onClick={handleExport}
onClick={() => setExportOpen(true)}
disabled={items.length === 0}
>
<Download className="h-3.5 w-3.5" /> {t('walletUi.stash.export')}
@ -1526,6 +1562,19 @@ export default function StashPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<StashBackupExportDialog
open={exportOpen}
onOpenChange={setExportOpen}
itemCount={items.length}
canUseKey={!!user?.signer?.nip44}
onExport={handleExport}
/>
<StashBackupUnlockDialog
open={unlockOpen}
onOpenChange={setUnlockOpen}
onUnlock={handleUnlock}
/>
</>
);
}