diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index aed5d0005..dfa68e7d5 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -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({
)}
- {variant === "spotlight" ? null : (
- <>
- {
- 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. */}
+ {
+ void handleFiles(event.currentTarget.files);
+ event.currentTarget.value = "";
+ }}
+ ref={fileInputRef}
+ tabIndex={-1}
+ type="file"
+ />
-
+
+ ) : (
+ {
+ 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"
+ >
+
+
+
+ Drop a key here
+
+
)}
{isEncryptedInput ? (
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 9ae672482..d82765496 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -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,
}) => {