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) {
|
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 ? (
|
||||||
|
|||||||
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user