fix(desktop): make saved .ncryptsec backups importable from file

Max's clean-room pass found the wipe -> import-from-file leg unreachable:
the file input accepted only .key,text/plain (macOS greys out the
identity.ncryptsec our own save dialog emits), and the first-launch
spotlight variant rendered no file input at all, leaving paste-only
recovery exactly where a returning user holds a backup file.

Accept .ncryptsec in the shared hidden file input, hoist it out of the
default-variant branch, and give the spotlight variant an 'Import from
a file' button wired to the same picker. Drop-zone behavior in the
default variant is unchanged. E2E covers the spotlight file path end to
end (accept attribute asserted explicitly, since setInputFiles bypasses
it) through decrypt-and-continue.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-28 10:07:51 -04:00
co-authored by Tyler Longwell
parent 51ea1b18f3
commit 72f00afa89
2 changed files with 139 additions and 79 deletions
@@ -96,7 +96,7 @@ export function NostrKeyImportForm({
if (file.size > NOSTR_KEY_FILE_MAX_BYTES) { if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
setImportError( setImportError(
"That file is too large to be a key. Drop a .key file or paste your nsec.", "That file is too large to be a key. Choose a .key or .ncryptsec backup file, or paste your key.",
); );
return; return;
} }
@@ -243,89 +243,107 @@ export function NostrKeyImportForm({
)} )}
</div> </div>
{variant === "spotlight" ? null : ( {/* Hidden file input shared by both variants: the default drop zone and
<> the spotlight "Import from a file" button both open it. Accepts the
<input .ncryptsec archives our own save flow emits alongside raw .key files. */}
accept=".key,text/plain" <input
className="sr-only" accept=".key,.ncryptsec,text/plain"
disabled={isInteractionDisabled} className="sr-only"
onChange={(event) => { data-testid="nostr-import-file-input"
void handleFiles(event.currentTarget.files); disabled={isInteractionDisabled}
event.currentTarget.value = ""; onChange={(event) => {
}} void handleFiles(event.currentTarget.files);
ref={fileInputRef} event.currentTarget.value = "";
tabIndex={-1} }}
type="file" ref={fileInputRef}
/> tabIndex={-1}
type="file"
/>
<button {variant === "spotlight" ? (
className={cn( // First-launch/wiped-identity treatment: no drop zone, but the file
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60", // path must still exist — a backup saved through the OS dialog is
isDragging && // exactly what a wiped user returns with.
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10", <div className="mt-2 text-center">
)} <Button
data-dragging={isDragging ? "true" : undefined} className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="nostr-import-drop" data-testid="nostr-import-file-button"
disabled={isInteractionDisabled} disabled={isInteractionDisabled}
onClick={openFilePicker} onClick={openFilePicker}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
setIsDragging(true);
}
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget.contains(event.relatedTarget as Node | null)
) {
return;
}
setIsDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (isInteractionDisabled) {
return;
}
void handleFiles(event.dataTransfer.files);
}}
type="button" type="button"
variant="ghost"
> >
<span Import from a file
aria-hidden="true" </Button>
className={cn( </div>
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out", ) : (
isDragging && "opacity-100", <button
)} className={cn(
/> "relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
<KeyRound isDragging &&
className={cn( "border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out", )}
isDragging && "text-primary", data-dragging={isDragging ? "true" : undefined}
)} data-testid="nostr-import-drop"
/> disabled={isInteractionDisabled}
<span onClick={openFilePicker}
className={cn( onDragEnter={(event) => {
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out", event.preventDefault();
isDragging && "text-primary", event.stopPropagation();
)} if (!isInteractionDisabled) {
> setIsDragging(true);
Drop a key here }
</span> }}
</button> onDragLeave={(event) => {
</> event.preventDefault();
event.stopPropagation();
if (
event.currentTarget.contains(event.relatedTarget as Node | null)
) {
return;
}
setIsDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (isInteractionDisabled) {
return;
}
void handleFiles(event.dataTransfer.files);
}}
type="button"
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
isDragging && "opacity-100",
)}
/>
<KeyRound
className={cn(
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
/>
<span
className={cn(
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
>
Drop a key here
</span>
</button>
)} )}
{isEncryptedInput ? ( {isEncryptedInput ? (
+42
View File
@@ -657,6 +657,48 @@ test("first-launch encrypted backup import asks for a passphrase and continues",
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
}); });
test("first-launch import accepts an .ncryptsec backup file", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
// The spotlight variant must expose a file path: a wiped user returns with
// exactly the identity.ncryptsec our own save dialog produced. The accept
// attribute is asserted explicitly because setInputFiles bypasses it — the
// OS picker is what filters on it in real use.
await expect(page.getByTestId("nostr-import-file-button")).toBeVisible();
const fileInput = page.getByTestId("nostr-import-file-input");
await expect(fileInput).toHaveAttribute(
"accept",
".key,.ncryptsec,text/plain",
);
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
await fileInput.setInputFiles({
buffer: Buffer.from(`${mockNcryptsec}\n`),
mimeType: "text/plain",
name: "identity.ncryptsec",
});
// File contents land in the key field and switch the form to encrypted mode.
await expect(page.getByTestId("nostr-import-encrypted-badge")).toBeVisible();
await page
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
});
test("non-local runtime override keeps community selection without release flag", async ({ test("non-local runtime override keeps community selection without release flag", async ({
page, page,
}) => { }) => {