mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
51ea1b18f3
commit
72f00afa89
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user