Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions app/src/features/account/BackupRestore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Restoring a copy (spec 14.2): the owner picks the file and types its
// passphrase; the daemon opens it and says what it holds; only after the
// owner confirms does Botloft restart and swap it in.

import { FolderOpen, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import type { BackupManifest } from "../../lib/protocol.gen";
import { useApi, useHost } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { TextField } from "../../ui/Field";
import { backupError } from "./BackupSettings";

export function BackupRestore() {
const b = useT().account.backup;
const api = useApi();
const host = useHost();
const [file, setFile] = useState<string | null>(null);
const [passphrase, setPassphrase] = useState("");
const [manifest, setManifest] = useState<BackupManifest | null>(null);
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState<{ title: string; text: string } | null>(null);

const pick = async () => {
const picked = await host.pickFile(b.pickTitle, b.kind, ["botloft"]);
if (picked) {
setFile(picked);
setManifest(null);
setFailed(null);
}
};

const open = async () => {
if (!file) {
return;
}
setBusy(true);
setFailed(null);
try {
setManifest(await api.call("backup.stage", { path: file, passphrase }));
} catch (failure) {
setFailed({ title: b.openFailed, text: backupError(failure, b.reasons) });
}
setBusy(false);
};

const restore = async () => {
setBusy(true);
setFailed(null);
try {
await api.call("backup.confirm");
await host.restartDaemon();
} catch (failure) {
setFailed({ title: b.restoreFailed, text: backupError(failure, b.reasons) });
setBusy(false);
}
};

const cancel = async () => {
await api.call("backup.cancel").catch(() => null);
setManifest(null);
setFile(null);
setPassphrase("");
};

const folders = (manifest?.crews ?? []).flatMap((crew) =>
crew.workFolder ? [crew.workFolder] : [],
);

return (
<div className="flex flex-col gap-3">
{!manifest && (
<>
<div className="flex items-center gap-3">
<Button icon={FolderOpen} onClick={pick} disabled={busy}>
{b.pick}
</Button>
{file && (
<span className="min-w-0 truncate font-mono text-muted text-xs" title={file}>
{file}
</span>
)}
</div>
{file && (
<div className="flex items-end gap-3">
<div className="min-w-0 flex-1">
<TextField
type="password"
autoComplete="current-password"
label={b.copyPassphrase}
value={passphrase}
onChange={(event) => setPassphrase(event.target.value)}
/>
</div>
<Button disabled={busy || passphrase.length === 0} onClick={open}>
{busy ? b.opening : b.open}
</Button>
</div>
)}
</>
)}
{manifest && (
<div className="flex flex-col gap-2 rounded-lg border border-line bg-sunken px-3 py-2.5 text-sm">
<p className="font-medium">{b.from(when(manifest.createdAt))}</p>
<ul className="flex flex-col gap-0.5 text-ink-soft">
{manifest.crews.map((crew) => (
<li key={crew.name}>{b.crew(crew.name, crew.bots.length)}</li>
))}
</ul>
{folders.length > 0 && (
<div className="text-muted text-xs">
<p>{b.workFolders}</p>
<ul className="mt-1 font-mono" data-selectable>
{folders.map((folder) => (
<li key={folder} className="break-all">
{folder}
</li>
))}
</ul>
</div>
)}
<p className="text-warn text-xs">{b.replaces}</p>
<div className="mt-1 flex flex-wrap gap-2">
<Button variant="danger" icon={RotateCcw} disabled={busy} onClick={restore}>
{busy ? b.restoring : b.restore}
</Button>
<Button variant="ghost" disabled={busy} onClick={cancel}>
{b.cancel}
</Button>
</div>
</div>
)}
{failed && (
<Callout tone="danger" title={failed.title}>
{failed.text}
</Callout>
)}
</div>
);
}
111 changes: 111 additions & 0 deletions app/src/features/account/BackupSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Settings, "Backup" (spec 14.2): save everything to one file locked with
// a passphrase, and restore such a file. The daemon makes the copy; the
// owner picks where it goes with the system's Save dialog.

import { Download } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { fileSize } from "../../lib/format";
import { RpcError } from "../../lib/rpc";
import { useApi, useHost } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { TextField } from "../../ui/Field";
import { BackupRestore } from "./BackupRestore";
import { Section } from "./settingsParts";

/** A failure as the owner reads it: the daemon's reason, worded, or the error. */
export function backupError(failure: unknown, reasons: Record<string, string>): string {
const reason = failure instanceof RpcError ? failure.reason : undefined;
return (reason && reasons[reason]) || errorText(failure);
}

type Outcome = { saved: string } | { notSaved: true } | { failed: string } | null;

export function BackupSettings() {
const b = useT().account.backup;
return (
<>
<Section title={b.exportTitle}>
<p className="text-ink-soft text-sm leading-relaxed">{b.exportIntro}</p>
<ExportCopy />
</Section>
<Section title={b.importTitle}>
<p className="text-ink-soft text-sm leading-relaxed">{b.importIntro}</p>
<BackupRestore />
</Section>
</>
);
}

function ExportCopy() {
const b = useT().account.backup;
const api = useApi();
const host = useHost();
const [passphrase, setPassphrase] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [outcome, setOutcome] = useState<Outcome>(null);
const mismatch = repeat.length > 0 && repeat !== passphrase;
const ready = [...passphrase].length >= 8 && repeat === passphrase;

const save = async () => {
setBusy(true);
setOutcome(null);
try {
const exported = await api.call("backup.export", { passphrase });
const kept = await host.saveFileAs(exported.path);
setOutcome(kept ? { saved: fileSize(exported.size) } : { notSaved: true });
if (kept) {
setPassphrase("");
setRepeat("");
}
} catch (failure) {
setOutcome({ failed: backupError(failure, b.reasons) });
}
setBusy(false);
};

return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<TextField
type="password"
autoComplete="new-password"
label={b.passphrase}
value={passphrase}
onChange={(event) => setPassphrase(event.target.value)}
/>
<TextField
type="password"
autoComplete="new-password"
label={b.repeat}
value={repeat}
onChange={(event) => setRepeat(event.target.value)}
/>
</div>
<p className={`text-xs ${mismatch ? "text-danger" : "text-muted"}`}>
{mismatch ? b.mismatch : b.passphraseHint}
</p>
<Button icon={Download} className="self-start" disabled={!ready || busy} onClick={save}>
{busy ? b.exporting : b.export}
</Button>
{outcome && "saved" in outcome && (
<p role="status" className="text-ok text-sm">
{b.saved(outcome.saved)}
</p>
)}
{outcome && "notSaved" in outcome && (
<p role="status" className="text-muted text-sm">
{b.notSaved}
</p>
)}
{outcome && "failed" in outcome && (
<Callout tone="danger" title={b.exportFailed}>
{outcome.failed}
</Callout>
)}
</div>
);
}
10 changes: 7 additions & 3 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Settings (spec 15.1), in parts listed on the left: general (what
// Botloft does in the background, the language), the chat, notifications,
// the look, what was archived, and the versions for when something needs
// reporting.
// the look, what was archived, backups, and the versions for when
// something needs reporting.

import {
Archive,
Bell,
DatabaseBackup,
Info,
type LucideIcon,
MessageSquare,
Expand All @@ -19,17 +20,19 @@ import { AboutSettings } from "./AboutSettings";
import { AlertsSettings } from "./AlertsSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { ArchivedSettings } from "./ArchivedSettings";
import { BackupSettings } from "./BackupSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "backup" | "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "alerts", icon: Bell },
{ id: "appearance", icon: Palette },
{ id: "archived", icon: Archive },
{ id: "backup", icon: DatabaseBackup },
{ id: "about", icon: Info },
];

Expand Down Expand Up @@ -79,6 +82,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
{page === "alerts" && <AlertsSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "archived" && <ArchivedSettings />}
{page === "backup" && <BackupSettings />}
{page === "about" && <AboutSettings />}
</div>
</div>
Expand Down
65 changes: 65 additions & 0 deletions app/src/features/account/backup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { FAKE_PASSPHRASE } from "../../lib/fakeBackup";
import { FakeHost } from "../../lib/fakeHost";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

async function openBackup() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
fake.addBot(ops.id, "Scout");
fake.addBot(ops.id, "Writer");
const host = new FakeHost();
renderApp(fake, host);
await crewOpened("Ops");
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
const dialog = screen.getByRole("dialog", { name: "Settings" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" }));
return { fake, host, dialog };
}

const type = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } });

describe("backup", () => {
test("a copy is saved where the owner picks, once both passphrases match", async () => {
const { fake, host, dialog } = await openBackup();
const save = within(dialog).getByRole("button", { name: "Save a copy…" });
expect(save).toHaveProperty("disabled", true);
type(within(dialog).getByLabelText("Passphrase"), "correct horse");
type(within(dialog).getByLabelText("Passphrase again"), "correct hors");
expect(within(dialog).getByText("The two passphrases are not the same.")).toBeDefined();
expect(save).toHaveProperty("disabled", true);
type(within(dialog).getByLabelText("Passphrase again"), "correct horse");
host.saveChosen = true;
fireEvent.click(save);
expect(await within(dialog).findByText(/^Copy saved \(/)).toBeDefined();
expect(host.savedFiles[0]).toMatch(/\.botloft$/);
const call = fake.calls.find((each) => each.method === "backup.export");
expect(call?.params).toEqual({ passphrase: "correct horse" });
});

test("a copy opens with its passphrase, shows what it holds and restores on restart", async () => {
const { fake, host, dialog } = await openBackup();
host.nextFile = "D:\\Copies\\botloft-2026-10-05-1430.botloft";
fireEvent.click(within(dialog).getByRole("button", { name: "Choose a copy…" }));
const field = await within(dialog).findByLabelText("The copy's passphrase");
expect(host.fileFilters[0]).toEqual(["botloft"]);

type(field, "wrong horse");
fireEvent.click(within(dialog).getByRole("button", { name: "Open" }));
expect(
await within(dialog).findByText("The passphrase is wrong, or the file is damaged."),
).toBeDefined();

type(field, FAKE_PASSPHRASE);
fireEvent.click(within(dialog).getByRole("button", { name: "Open" }));
expect(await within(dialog).findByText("Ops: 2 bots")).toBeDefined();
fireEvent.click(within(dialog).getByRole("button", { name: "Restore and restart" }));
await waitFor(() => expect(host.installs).toContain("restart"));
expect(fake.calls.some((each) => each.method === "backup.confirm")).toBe(true);
});
});
Loading
Loading