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) {
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;
}
@@ -243,89 +243,107 @@ export function NostrKeyImportForm({
)}
</div>
{variant === "spotlight" ? null : (
<>
<input
accept=".key,text/plain"
className="sr-only"
disabled={isInteractionDisabled}
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
{/* Hidden file input shared by both variants: the default drop zone and
the spotlight "Import from a file" button both open it. Accepts the
.ncryptsec archives our own save flow emits alongside raw .key files. */}
<input
accept=".key,.ncryptsec,text/plain"
className="sr-only"
data-testid="nostr-import-file-input"
disabled={isInteractionDisabled}
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<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",
isDragging &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
)}
data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop"
{variant === "spotlight" ? (
// First-launch/wiped-identity treatment: no drop zone, but the file
// path must still exist — a backup saved through the OS dialog is
// exactly what a wiped user returns with.
<div className="mt-2 text-center">
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="nostr-import-file-button"
disabled={isInteractionDisabled}
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"
variant="ghost"
>
<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>
</>
Import from a file
</Button>
</div>
) : (
<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",
isDragging &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
)}
data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop"
disabled={isInteractionDisabled}
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"
>
<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 ? (
+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();
});
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 ({
page,
}) => {