diff --git a/.changeset/first_run_setup_wizard.md b/.changeset/first_run_setup_wizard.md
new file mode 100644
index 0000000..193505a
--- /dev/null
+++ b/.changeset/first_run_setup_wizard.md
@@ -0,0 +1,12 @@
+---
+default: minor
+---
+
+# First-run setup wizard
+
+A fresh install now asks where your vault should come from instead of only
+offering to create one: start a new vault, import a vault file you exported
+elsewhere, or — in the extension — use the vault in the desktop app. Importing
+on first run adopts the file, so the passphrase that opens it unlocks this
+device too. After creating a vault the desktop app offers to turn on the browser
+bridge and show a pairing code.
diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml
index bbad2ca..1349693 100644
--- a/.github/workflows/changeset.yml
+++ b/.github/workflows/changeset.yml
@@ -8,9 +8,10 @@ name: Changeset
# The job keeps a single sticky comment on the PR (found by a hidden marker):
# - no changeset -> "changeset required" + how to add one, and the check fails
# - changeset(s) -> "changeset detected" + each file's contents in
-# Escape hatch: label the PR "skip-changeset" for chore/docs/ci work that
-# doesn't warrant a release entry. The labeled/unlabeled triggers re-run when
-# the label is toggled.
+# Escape hatches: label the PR "skip-changeset" for chore/docs/ci work that
+# doesn't warrant a release entry (the labeled/unlabeled triggers re-run when
+# the label is toggled), and Knope's own release PR — head branch `release` —
+# is exempt, since it consumes the change files instead of adding one.
on:
pull_request:
branches: [main]
@@ -35,9 +36,15 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
set -euo pipefail
+ if [ "$HEAD_REF" = "release" ]; then
+ echo "::notice title=Changeset skipped::Knope release PR — it consumes change files rather than adding one."
+ exit 0
+ fi
+
case ",$LABELS," in
*,skip-changeset,*)
echo "::notice title=Changeset skipped::PR is labeled 'skip-changeset'."
diff --git a/apps/twofau-app/src-tauri/src/lib.rs b/apps/twofau-app/src-tauri/src/lib.rs
index 25eef93..ba4c64b 100644
--- a/apps/twofau-app/src-tauri/src/lib.rs
+++ b/apps/twofau-app/src-tauri/src/lib.rs
@@ -230,6 +230,33 @@ async fn import_vault(
Ok(Some(count))
}
+/// First run only: adopt a picked vault file as this device's vault, keeping
+/// the passphrase it was sealed with. Returns the account count, or None if the
+/// user cancels the picker. Errors are the codes `AppVault::adopt_blob` returns,
+/// which the setup wizard turns into its own messages.
+#[tauri::command]
+async fn adopt_vault(
+ app: AppHandle,
+ vault: State<'_, Arc>,
+ dialog_open: State<'_, DialogOpen>,
+ passphrase: String,
+) -> Result, String> {
+ let _guard = DialogGuard::new(&dialog_open);
+ let Some(path) = app
+ .dialog()
+ .file()
+ .add_filter("2FAU vault", &["dat"])
+ .blocking_pick_file()
+ else {
+ return Ok(None);
+ };
+ let path = path.into_path().map_err(|e| e.to_string())?;
+ let blob = std::fs::read(&path).map_err(|_| "bad-file".to_string())?;
+ let count = vault.adopt_blob(&blob, &passphrase)?;
+ refresh_tray(&app);
+ Ok(Some(count))
+}
+
#[tauri::command]
async fn change_passphrase(
vault: State<'_, Arc>,
@@ -576,6 +603,7 @@ pub fn run() {
list_accounts,
export_vault,
import_vault,
+ adopt_vault,
change_passphrase,
parse_migration,
code,
diff --git a/apps/twofau-app/src-tauri/src/vault.rs b/apps/twofau-app/src-tauri/src/vault.rs
index d63f54a..9c9c429 100644
--- a/apps/twofau-app/src-tauri/src/vault.rs
+++ b/apps/twofau-app/src-tauri/src/vault.rs
@@ -334,6 +334,29 @@ impl AppVault {
Ok(count)
}
+ /// Adopt an exported blob as *this* device's vault, before one exists. The
+ /// file's own bytes are written verbatim, so its salt, nonce and KDF id
+ /// carry over and the passphrase that opens the file becomes this device's.
+ /// Unlike `import_blob` (which merges into an unlocked vault) this replaces,
+ /// so it refuses once a vault is there. Leaves the vault locked: the caller
+ /// unlocks through the ordinary path, which also caches the passphrase.
+ ///
+ /// Errors are machine-readable codes — the first-run UI tells "wrong
+ /// passphrase" (retry) from "not a vault file" (pick another).
+ pub fn adopt_blob(&self, blob: &[u8], passphrase: &str) -> Result {
+ if self.has_vault() {
+ return Err("vault-exists".to_string());
+ }
+ let doc = open_with_passphrase(blob, passphrase).map_err(|e| match e {
+ twofau_core::VaultError::DecryptFailed => "wrong-passphrase".to_string(),
+ _ => "bad-file".to_string(),
+ })?;
+ let count = doc.entries.len();
+ let mut rev = self.revision.lock().expect("revision mutex");
+ self.write_blob_locked(&mut rev, blob)?;
+ Ok(count)
+ }
+
/// Re-seal the vault under a new passphrase after checking the current one.
/// Also updates the remembered passphrase so silent auto-unlock keeps working.
pub fn change_passphrase(&self, current: &str, next: &str) -> Result<(), String> {
@@ -554,6 +577,70 @@ mod tests {
assert_eq!(reopened.list().unwrap().len(), 2);
}
+ #[test]
+ fn adopt_takes_over_an_exported_vault_with_its_own_passphrase() {
+ let (a, _da) = fresh();
+ a.unlock(PASS.into(), false).unwrap();
+ a.add_uri("otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP&issuer=Acme")
+ .unwrap();
+ a.add_uri("otpauth://totp/Beta:you?secret=JBSWY3DPEHPK3PXP&issuer=Beta")
+ .unwrap();
+ let blob = a.export_blob().unwrap();
+
+ // A device with no vault of its own adopts the file, then unlocks with
+ // the passphrase the file was sealed under.
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("vault.dat");
+ let b = AppVault::new(path.clone());
+ assert_eq!(b.adopt_blob(&blob, PASS).unwrap(), 2);
+ assert!(b.is_locked(), "adopting does not unlock");
+ b.unlock(PASS.into(), false).unwrap();
+ assert_eq!(b.list().unwrap().len(), 2);
+
+ // And it survives a restart under that same passphrase.
+ let reopened = AppVault::new(path);
+ reopened.unlock(PASS.into(), false).unwrap();
+ assert_eq!(reopened.list().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn adopt_reports_a_wrong_passphrase_apart_from_a_foreign_file() {
+ let (a, _da) = fresh();
+ a.unlock(PASS.into(), false).unwrap();
+ let blob = a.export_blob().unwrap();
+
+ let dir = tempdir().unwrap();
+ let b = AppVault::new(dir.path().join("vault.dat"));
+ assert_eq!(
+ b.adopt_blob(&blob, "not-the-passphrase"),
+ Err("wrong-passphrase".into())
+ );
+ assert_eq!(
+ b.adopt_blob(b"not a vault at all", PASS),
+ Err("bad-file".into())
+ );
+ assert!(!b.has_vault(), "a failed adopt writes nothing");
+ }
+
+ #[test]
+ fn adopt_refuses_once_a_vault_exists() {
+ let (a, _da) = fresh();
+ a.unlock(PASS.into(), false).unwrap();
+ a.add_uri("otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP&issuer=Acme")
+ .unwrap();
+ let blob = a.export_blob().unwrap();
+
+ // B already has its own vault: adopting is refused and B is untouched.
+ let (b, _db) = fresh();
+ b.unlock("a-different-passphrase".into(), false).unwrap();
+ b.add_uri("otpauth://totp/Beta:you?secret=JBSWY3DPEHPK3PXP&issuer=Beta")
+ .unwrap();
+ assert_eq!(b.adopt_blob(&blob, PASS), Err("vault-exists".into()));
+ let accounts = b.list().unwrap();
+ assert_eq!(accounts.len(), 1);
+ assert_eq!(accounts[0].issuer, "Beta");
+ }
+
#[test]
fn import_rejects_a_wrong_file_passphrase() {
let (a, _da) = fresh();
diff --git a/apps/twofau-app/src/main.tsx b/apps/twofau-app/src/main.tsx
index 20030da..08282e2 100644
--- a/apps/twofau-app/src/main.tsx
+++ b/apps/twofau-app/src/main.tsx
@@ -10,6 +10,7 @@ import { initAutoLock } from "./auto-lock";
import { getQuickCopy } from "./hotkeys";
import { getLocale } from "./locale";
import { tauriSettingsBackend } from "./settings-backend";
+import { tauriSetupBackend } from "./setup-backend";
import { TauriVaultService } from "./tauri-vault-service";
import "./index.css";
@@ -29,6 +30,7 @@ function Root({
const containerRef = useRef(null);
const service = useRef(new TauriVaultService(startUnlocked, needsSetup)).current;
const settingsBackend = useMemo(() => tauriSettingsBackend(version), [version]);
+ const setupBackend = useMemo(() => tauriSetupBackend(), []);
const quickCopy = useMemo(() => getQuickCopy(), []);
// Keep the OS window's height matched to the panel content (like the Swift
@@ -73,6 +75,7 @@ function Root({
messages={messages}
onQuit={() => void invoke("quit")}
settingsBackend={settingsBackend}
+ setupBackend={setupBackend}
parseMigration={(uri) => invoke("parse_migration", { uri })}
readClipboard={async () => (await readText()) ?? ""}
writeClipboard={(text) => writeText(text)}
diff --git a/apps/twofau-app/src/setup-backend.tsx b/apps/twofau-app/src/setup-backend.tsx
new file mode 100644
index 0000000..f85f3cf
--- /dev/null
+++ b/apps/twofau-app/src/setup-backend.tsx
@@ -0,0 +1,102 @@
+import { invoke } from "@tauri-apps/api/core";
+import { useState } from "react";
+import { useT } from "@twofau/ui";
+import type { AdoptError, AdoptFailure, SetupBackend } from "@twofau/ui";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+/** `adopt_vault` rejects with a bare code; hand the wizard a tagged Error so it
+ * can say which of the two went wrong. */
+function adoptError(err: unknown): AdoptError {
+ const raw = String(err);
+ const code: AdoptFailure | undefined =
+ raw === "wrong-passphrase" || raw === "bad-file" ? raw : undefined;
+ const tagged: AdoptError = new Error(raw);
+ tagged.code = code;
+ return tagged;
+}
+
+/** The desktop's first-run extras: adopt a vault file, then (optionally) turn on
+ * the bridge so a browser can use this vault. */
+export function tauriSetupBackend(): SetupBackend {
+ return {
+ adopt: {
+ kind: "native",
+ run: async (passphrase) => {
+ try {
+ return await invoke("adopt_vault", { passphrase });
+ } catch (err) {
+ throw adoptError(err);
+ }
+ },
+ },
+ connect: {
+ placement: "after-vault",
+ title: "Connect your browser",
+ description: "Let the 2FAU extension use this vault.",
+ screen: ({ onDone, onSkip }) => ,
+ },
+ };
+}
+
+function ConnectBrowserStep({ onDone, onSkip }: { onDone: () => void; onSkip: () => void }) {
+ const { t } = useT();
+ const [code, setCode] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Enabling and minting the code are one action here: the only reason to be on
+ // this screen is to pair a browser. Skipping leaves the bridge off.
+ async function enable() {
+ setBusy(true);
+ setError(null);
+ try {
+ const status = await invoke<{ port: number }>("bridge_status");
+ await invoke("bridge_enable", { on: true, port: status.port });
+ setCode(await invoke("bridge_pairing_code"));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
{t("Connect your browser")}
+
+ {t(
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.",
+ )}
+
+
+ {code === null ? (
+
void enable()}>
+ {busy ? t("Starting…") : t("Turn on the bridge")}
+
+ ) : (
+ <>
+
e.currentTarget.select()}
+ className="text-center font-mono text-[16px] tracking-[0.35em] tabular-nums"
+ />
+
+ {t("Enter this code in the extension. It expires in about two minutes.")}
+
+
+ {t("Done")}
+
+ >
+ )}
+
+ {error &&
{error}
}
+
+
+ {code === null ? t("Skip for now") : t("Close")}
+
+
+ );
+}
diff --git a/apps/twofau-extension/src/popup/main.tsx b/apps/twofau-extension/src/popup/main.tsx
index 860e739..2a89432 100644
--- a/apps/twofau-extension/src/popup/main.tsx
+++ b/apps/twofau-extension/src/popup/main.tsx
@@ -1,5 +1,6 @@
import type { VaultService } from "@twofau/ui";
import {
+ createTranslator,
I18nProvider,
loadMessages,
modsFromToken,
@@ -15,6 +16,8 @@ import ReactDOM from "react-dom/client";
import { BridgeUnreachableError } from "../bridge/connection";
import { SCAN_MESSAGE } from "../shared/messages";
import { createVaultService } from "../vault/backend";
+import { extensionSetupBackend } from "./setup-backend";
+import type { ExtensionVaultService } from "../vault/extension-vault-service";
import { readSettings } from "../vault/settings";
import { accountMatchesSite, hostOf } from "../vault/site-match";
import { initWasm } from "../wasm";
@@ -93,9 +96,17 @@ async function bootstrap() {
mods: modsFromToken(settings?.quickCopyMods ?? "mod"),
};
+ // First run offers more than "create a passphrase": adopt an exported vault,
+ // or hand the whole thing to the desktop app. The service is the local one
+ // either way until a mode is chosen — only `client` mode needs a reload.
+ const setupBackend = service.needsSetup()
+ ? extensionSetupBackend(service as ExtensionVaultService, createTranslator(locale, messages))
+ : undefined;
+
root.render(
service.adoptBlob(await readFileBytes(file), passphrase),
+ },
+ connect: {
+ placement: "choice",
+ title: t("Use my desktop app"),
+ description: t("Keep the vault in 2FAU on this computer."),
+ screen: ({ onDone, onSkip }) => ,
+ },
+ };
+}
+
+/** Pair with the desktop bridge and pick where the vault lives. Nothing is
+ * written until pairing succeeds, so a failure leaves the extension exactly as
+ * it was — no vault, no mode change. */
+function ConnectDesktop({ onDone, onSkip }: { onDone: () => void; onSkip: () => void }) {
+ const { t } = useT();
+ const [code, setCode] = useState("");
+ const [mode, setMode] = useState("client");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function connect() {
+ setBusy(true);
+ setError(null);
+ try {
+ if ((await connectToDesktop(code.trim(), mode)) === "permission-declined") {
+ setError(t("2FAU needs permission to talk to 127.0.0.1 to reach the desktop app."));
+ return;
+ }
+ // The backend is chosen at startup from the mode we just wrote, so the
+ // popup has to come back up against the desktop vault.
+ onDone();
+ window.location.reload();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
{t("Use my desktop app")}
+
+ {t(
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.",
+ )}
+
+
+
setCode(e.target.value)}
+ className="rounded-md border bg-input/30 px-3 py-2 text-center font-mono text-[16px] tracking-[0.35em] tabular-nums"
+ />
+
+
+ {(
+ [
+ ["client", t("Desktop vault"), t("Nothing is stored in this browser.")],
+ ["sync", t("Sync with desktop"), t("Keep a copy here, unlocked by the same passphrase.")],
+ ] as const
+ ).map(([value, label, help]) => (
+
+ setMode(value)}
+ />
+
+ {label}
+ {help}
+
+
+ ))}
+
+
+
void connect()}
+ className="rounded-md bg-primary px-3 py-2 text-[13px] font-medium text-primary-foreground disabled:opacity-50"
+ >
+ {busy ? t("Pairing…") : t("Pair")}
+
+
+ {error &&
{error}
}
+
+
+ {t("Back")}
+
+
+ );
+}
diff --git a/apps/twofau-extension/src/vault/connect-desktop.test.ts b/apps/twofau-extension/src/vault/connect-desktop.test.ts
new file mode 100644
index 0000000..ded7ee7
--- /dev/null
+++ b/apps/twofau-extension/src/vault/connect-desktop.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { installFakeChrome } from "../test/fake-chrome";
+import { createVaultService } from "./backend";
+import { connectToDesktop, type ConnectDeps } from "./connect-desktop";
+import { readSettings, writeSettings } from "./settings";
+
+function deps(over: Partial = {}) {
+ return {
+ ensurePermission: vi.fn(async () => true),
+ pair: vi.fn(async () => {}),
+ saveMode: vi.fn(async () => {}),
+ ...over,
+ };
+}
+
+beforeEach(() => {
+ installFakeChrome();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("connectToDesktop", () => {
+ it("pairs first and only then saves the mode", async () => {
+ const d = deps();
+ expect(await connectToDesktop("123456", "client", d)).toBe("paired");
+ expect(d.pair).toHaveBeenCalledWith("123456");
+ expect(d.saveMode).toHaveBeenCalledWith("client");
+ });
+
+ it("saves nothing when the loopback permission is declined", async () => {
+ const d = deps({ ensurePermission: vi.fn(async () => false) });
+ expect(await connectToDesktop("123456", "client", d)).toBe("permission-declined");
+ expect(d.pair).not.toHaveBeenCalled();
+ expect(d.saveMode).not.toHaveBeenCalled();
+ expect((await readSettings()).mode).toBe("independent");
+ });
+
+ it("saves nothing when the desktop is unreachable", async () => {
+ const d = deps({ pair: vi.fn(async () => Promise.reject(new Error("Desktop app not reachable"))) });
+ await expect(connectToDesktop("123456", "sync", d)).rejects.toThrow(/not reachable/i);
+ expect(d.saveMode).not.toHaveBeenCalled();
+ expect((await readSettings()).mode).toBe("independent");
+ });
+
+ it("leaves the next popup start talking to the desktop, not chrome.storage", async () => {
+ // What the reload after a successful connect does: the mode written here is
+ // what picks the backend.
+ const fetchMock = vi.fn(async () => new Response("{}", { status: 404 }));
+ vi.stubGlobal("fetch", fetchMock);
+ await writeSettings({ mode: "independent" });
+
+ await connectToDesktop("123456", "client", {
+ ...deps(),
+ saveMode: async (mode) => {
+ await writeSettings({ mode });
+ },
+ });
+
+ const service = await createVaultService();
+ expect(service.needsSetup()).toBe(true); // 404: the desktop has no vault yet
+ expect(fetchMock).toHaveBeenCalled(); // …asked the desktop, not local storage
+ });
+
+ it("saves nothing when the pairing code has expired", async () => {
+ const d = deps({ pair: vi.fn(async () => Promise.reject(new Error("Pairing failed (401)"))) });
+ await expect(connectToDesktop("000000", "client", d)).rejects.toThrow(/401/);
+ expect(d.saveMode).not.toHaveBeenCalled();
+ expect((await readSettings()).mode).toBe("independent");
+ });
+});
diff --git a/apps/twofau-extension/src/vault/connect-desktop.ts b/apps/twofau-extension/src/vault/connect-desktop.ts
new file mode 100644
index 0000000..31c5c88
--- /dev/null
+++ b/apps/twofau-extension/src/vault/connect-desktop.ts
@@ -0,0 +1,37 @@
+import { ensureBridgePermission, pairBridge } from "../bridge/connection";
+import { writeSettings, type BridgeMode } from "./settings";
+
+export type ConnectOutcome = "paired" | "permission-declined";
+
+/** What `connectToDesktop` needs from the browser; injectable so the ordering
+ * rule below can be tested without a live bridge. */
+export interface ConnectDeps {
+ ensurePermission: () => Promise;
+ pair: (code: string) => Promise;
+ saveMode: (mode: BridgeMode) => Promise;
+}
+
+const REAL: ConnectDeps = {
+ ensurePermission: ensureBridgePermission,
+ pair: pairBridge,
+ saveMode: async (mode) => {
+ await writeSettings({ mode });
+ },
+};
+
+/**
+ * First-run pairing: ask for the loopback permission, submit the desktop's
+ * code, and only then persist the mode. The order is the point — a declined
+ * permission or a failed pairing must leave the extension exactly as it was, so
+ * the popup never comes back up in a mode it cannot serve.
+ */
+export async function connectToDesktop(
+ code: string,
+ mode: BridgeMode,
+ deps: ConnectDeps = REAL,
+): Promise {
+ if (!(await deps.ensurePermission())) return "permission-declined";
+ await deps.pair(code);
+ await deps.saveMode(mode);
+ return "paired";
+}
diff --git a/apps/twofau-extension/src/vault/extension-vault-service.test.ts b/apps/twofau-extension/src/vault/extension-vault-service.test.ts
index 7c5cd38..852ce58 100644
--- a/apps/twofau-extension/src/vault/extension-vault-service.test.ts
+++ b/apps/twofau-extension/src/vault/extension-vault-service.test.ts
@@ -37,6 +37,57 @@ describe("ExtensionVaultService", () => {
expect(reopened.needsSetup()).toBe(false);
});
+ it("adopts an exported vault as this browser's, keeping its passphrase", async () => {
+ // A vault that came from somewhere else (another browser, the desktop).
+ const source = await freshService();
+ await source.unlock("the-other-devices-passphrase");
+ await source.addUri(URI);
+ const blob = await source.exportBlob();
+
+ // A browser with nothing of its own takes it over.
+ installFakeChrome();
+ await clearSessionKey();
+ const fresh = await freshService();
+ expect(fresh.needsSetup()).toBe(true);
+ expect(await fresh.adoptBlob(blob, "the-other-devices-passphrase")).toBe(1);
+
+ expect(fresh.needsSetup()).toBe(false);
+ expect((await fresh.list())[0].issuer).toBe("Acme");
+
+ // And that same passphrase unlocks it from now on.
+ await clearSessionKey();
+ const reopened = await freshService();
+ await reopened.unlock("the-other-devices-passphrase");
+ expect(await reopened.list()).toHaveLength(1);
+ });
+
+ it("refuses to adopt with the wrong passphrase or a foreign file, writing nothing", async () => {
+ const source = await freshService();
+ await source.unlock(PASSPHRASE);
+ const blob = await source.exportBlob();
+
+ installFakeChrome();
+ await clearSessionKey();
+ const fresh = await freshService();
+
+ await expect(fresh.adoptBlob(blob, "not-the-passphrase")).rejects.toMatchObject({
+ code: "wrong-passphrase",
+ });
+ await expect(fresh.adoptBlob(new Uint8Array([1, 2, 3]), PASSPHRASE)).rejects.toMatchObject({
+ code: "bad-file",
+ });
+ expect(fresh.needsSetup()).toBe(true);
+ expect((await freshService()).needsSetup()).toBe(true);
+ });
+
+ it("refuses to adopt over an existing vault", async () => {
+ const source = await freshService();
+ await source.unlock(PASSPHRASE);
+ const blob = await source.exportBlob();
+
+ await expect(source.adoptBlob(blob, PASSPHRASE)).rejects.toThrow(/already has a vault/i);
+ });
+
it("rejects the wrong passphrase and stays locked", async () => {
await (await freshService()).unlock(PASSPHRASE);
await clearSessionKey();
diff --git a/apps/twofau-extension/src/vault/extension-vault-service.ts b/apps/twofau-extension/src/vault/extension-vault-service.ts
index 858a407..cf2a0d6 100644
--- a/apps/twofau-extension/src/vault/extension-vault-service.ts
+++ b/apps/twofau-extension/src/vault/extension-vault-service.ts
@@ -15,6 +15,8 @@ import {
import type {
Account,
AddManualFields,
+ AdoptError,
+ AdoptFailure,
Capabilities,
StoredAccount,
VaultDocument,
@@ -25,6 +27,13 @@ import { clearSessionKey, getSessionKey, setSessionKey, touchSessionKey } from "
import { getTimeOffsetMs } from "./time-sync";
import { VaultRepo, type VaultManifest, type VaultRepoPort } from "./vault-repo";
+/** Tag a first-run adopt failure so the setup wizard can tell them apart. */
+function adoptError(code: AdoptFailure, message: string, cause?: unknown): AdoptError {
+ const err: AdoptError = new Error(message, cause === undefined ? undefined : { cause });
+ err.code = code;
+ return err;
+}
+
/** PBKDF2-HMAC-SHA256; the only KDF the blob format defines today. */
export const KDF_ID = 1;
@@ -287,6 +296,43 @@ export class ExtensionVaultService implements VaultService {
return merged.entries.length;
}
+ /**
+ * First run only: adopt an exported blob as this browser's vault, keeping the
+ * passphrase it was sealed with. The bytes are stored verbatim — salt, nonce
+ * and kdf id included — so the same file opens identically on every host.
+ * Unlike `importBlob` (which merges into an unlocked vault) this replaces, so
+ * it refuses once a vault exists. Rejections carry an `AdoptError` code.
+ */
+ async adoptBlob(blob: Uint8Array, passphrase: string): Promise {
+ // Not an AdoptError code: the wizard only runs before a vault exists, so
+ // reaching this is a bug rather than something to explain to the user.
+ if (this.vaultExists) throw new Error("This browser already has a vault.");
+
+ let salt: string;
+ try {
+ salt = await vaultSalt(blob);
+ } catch (cause) {
+ throw adoptError("bad-file", "That file is not a 2FAU vault.", cause);
+ }
+
+ const key = await deriveKey(passphrase, salt);
+ let doc: VaultDocument;
+ try {
+ doc = await openWithKey(blob, key);
+ } catch (cause) {
+ throw adoptError("wrong-passphrase", "That passphrase does not open this file.", cause);
+ }
+
+ // kdfId travels in the blob's own header (byte 5); the manifest only mirrors it.
+ const saved = await this.repo.save(blob, salt, blob[5] ?? KDF_ID, 0);
+ if (!saved.ok) throw new Error("Another vault appeared while importing.");
+ await setSessionKey(key);
+ this.cached = { revision: saved.manifest.revision, doc };
+ this.vaultExists = true;
+ this.unlocked = true;
+ return doc.entries.length;
+ }
+
// MARK: internals
private async requireKey(): Promise {
diff --git a/openspec/changes/add-first-run-setup-wizard/.openspec.yaml b/openspec/changes/add-first-run-setup-wizard/.openspec.yaml
new file mode 100644
index 0000000..eaa6b1c
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/add-first-run-setup-wizard/design.md b/openspec/changes/add-first-run-setup-wizard/design.md
new file mode 100644
index 0000000..266cde0
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/design.md
@@ -0,0 +1,112 @@
+## Context
+
+See proposal.md — Why. The pieces that constrain the approach:
+
+- `RootView` renders `SetupView` when `useVault().needsSetup` is true. `SetupView`
+ calls `VaultService.unlock(passphrase)`, which on a host with no vault *creates*
+ one — so the create path already exists and needs no new operation.
+- `@twofau/ui` may not import `chrome.*` or Tauri APIs. Host I/O reaches it
+ either through `VaultService` or through an injected backend object; the
+ settings screen already uses the second shape (`SettingsBackend`, including
+ `ImportSpec`'s `native` / `file` split and a ready-made `sync` sub-screen).
+- Importing today is `AppVault::import_blob`, which fails with "vault is locked"
+ unless a vault is already open, and merges into it. It cannot serve first run.
+- The extension popup builds its backend *before* rendering: `createVaultService()`
+ reads `mode` and, in `client` mode with no pairing, throws — the popup then
+ renders `DesktopUnavailable`. Only that mode throws, so a first-run user (whose
+ `mode` still reads its `independent` default) gets a working local backend.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- One wizard component in `@twofau/ui`, rendered for every host, with the
+ host-specific steps injected.
+- First run cannot end in a state the user did not pick — no silent
+ `independent` mode, no vault created just to get past a screen.
+
+**Non-Goals:**
+
+- Changing the vault format, the bridge protocol, or the pairing rules.
+- Any Settings re-entry point, any "redo setup" affordance.
+- Migrating existing installs: they have a vault, so they never see the wizard.
+
+## Decisions
+
+**The wizard is a `SetupBackend` injected next to `SettingsBackend`, not new
+`VaultService` methods.** `TwoFAUApp` grows an optional `setupBackend` whose
+fields describe the starting points: `create` is implied (it is `unlock()`),
+`adopt?: ImportSpec` reuses the existing `native` | `file` union verbatim, and
+`connect?: { screen: ReactNode }` is a host-rendered sub-screen exactly like
+`SettingsBackend.sync`. Which choices the wizard shows is which fields are
+present. Alternative: add `adopt()` and `setupOptions()` to `VaultService`. That
+port is the steady-state contract every screen uses and every implementation
+(Tauri, extension, mock) must satisfy; hanging first-run-only I/O and a whole
+pairing sub-screen off it means `MockVaultService` grows machinery no test needs.
+The `SettingsBackend` precedent already says host-shaped screens travel as
+injected props.
+
+**Import adopts by replacing, not merging.** With no vault present there is
+nothing to merge into, and asking for a local passphrase first would mean two
+passphrases for one vault. Desktop: a new command verifies the chosen blob opens
+with the supplied passphrase, writes those bytes to `app_data_dir()/vault.dat`,
+then unlocks normally — the file's own salt, nonce, and KDF id carry over
+untouched. Extension: the same, writing the blob through the existing repo as the
+first generation and putting the derived key in session storage. `import_blob`
+stays as it is for the Settings path, where merging is the right behavior.
+
+**Adopt refuses when a vault already exists.** The command is first-run only;
+guarding it there keeps "import" (merge, from Settings) and "adopt" (replace,
+from the wizard) from ever being the same button pointed at different states.
+
+**The extension represents "not yet onboarded" by the absence of a vault, not by
+a new flag.** `service.needsSetup()` is already that signal, so the popup keeps
+its current bootstrap: `createVaultService()` in the default `independent` mode
+builds the local backend without touching the bridge, and the wizard renders
+over it. Only the connect step changes the backend, and it reloads the popup
+after writing the mode, so a `client`-mode service is never built before a
+pairing exists. An earlier draft of this design had the popup render the wizard
+*instead of* calling `createVaultService()`; that turned out to be unnecessary —
+only `client` mode can throw there, and no first-run user is in it. An explicit
+`onboarded` boolean would also be a second source of truth that upgrades would
+have to backfill for every existing install.
+
+**The connect step runs after the vault exists on desktop, and instead of a vault
+on the extension.** That asymmetry is inherent: the desktop *is* the vault, so
+the bridge is an extra; the extension in `client` mode has no vault of its own, so
+connecting is a complete starting point. The wizard reflects the host rather than
+forcing one shape on both.
+
+**Existing `SetupView` becomes the create step.** Same form, same minimum length,
+same `unlock()` call — moved one level down in the tree rather than rewritten.
+
+## Risks / Trade-offs
+
+- A second injected backend object next to `SettingsBackend` is more surface than
+ extending `VaultService` → both are already established shapes; the wizard adds
+ no new *kind* of seam, and its fields are optional so hosts opt in.
+- Adopting a blob writes a file whose KDF id and version came from another
+ install, possibly older → the blob is opened before it is adopted, so an
+ unsupported version fails as `UnsupportedVersion`/`UnknownKdf` at that point,
+ before anything is written.
+- The connect step changes which backend the popup should be using → it reloads
+ the popup right after pairing, so the next start reads the new mode. A reload
+ mid-wizard is safe: pairing and the mode are already persisted.
+- Every new string needs a translation pass across the locale catalogs → English
+ is the key, untranslated locales fall back to it, so a partial pass ships
+ correct English rather than blanks.
+- The desktop wizard's tray/popup interactions cannot be verified headlessly →
+ Vitest covers the wizard against `MockVaultService`; the desktop path is
+ confirmed by a human running the app, and reported that way.
+
+## Migration Plan
+
+Nothing to migrate: the wizard is gated on the host reporting no vault, so every
+existing install goes straight to unlock as before. Rollback is reverting the UI
+package and the two hosts; an adopted vault stays readable, since adoption
+produces an ordinary vault file with no new format.
+
+## Open Questions
+
+Resolved during implementation: the extension's connect step offers `client` and
+`sync` on the same screen, defaulting to `client`.
diff --git a/openspec/changes/add-first-run-setup-wizard/proposal.md b/openspec/changes/add-first-run-setup-wizard/proposal.md
new file mode 100644
index 0000000..6770451
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/proposal.md
@@ -0,0 +1,72 @@
+## Why
+
+A fresh install drops the user straight onto "Create a passphrase" — the only
+path the UI offers. Someone who already has a vault (exported from another
+machine, or living in the desktop app) has no way to say so: they must create a
+throwaway vault first, then find Settings → import or Settings → Sync and undo
+what the setup screen just made them do. The extension is worse: its mode
+silently defaults to `independent`, so a user who installed it *because* they run
+the desktop app ends up with a second, disconnected vault and no hint that
+pairing exists.
+
+Every capability needed is already built — import, bridge pairing, the three
+extension modes. What is missing is the one screen that offers them at the moment
+the choice is actually being made.
+
+## What Changes
+
+- A first-run wizard replaces the bare `SetupView` as what the UI renders when
+ the host reports no vault. It presents the host's available starting points
+ rather than assuming "create".
+- **Desktop**: choose *create a new vault* or *import a vault file*, then an
+ optional *connect your browser* step that enables the bridge and shows the
+ pairing code. The connect step is skippable and stays available in Settings.
+- **Extension**: choose *create a new vault*, *import a vault file*, or *connect
+ to the desktop app* — the last pairs with the desktop and sets `client` or
+ `sync` mode, so the extension's mode is chosen deliberately instead of
+ defaulting.
+- Importing on first run **adopts** the file: the blob becomes this device's
+ vault and the passphrase that opens the file becomes this device's passphrase.
+ This is a new host operation — today's import merges into an already-unlocked
+ vault and cannot run before one exists.
+- The wizard is first-run only. It is not added to Settings; everything it sets
+ up remains editable there.
+
+## Capabilities
+
+### New Capabilities
+
+- `onboarding`: what a host offers the user on first run — the available starting
+ points, the order of the steps, what each one leaves behind, and what happens
+ when one is skipped or fails.
+
+### Modified Capabilities
+
+- `shared-ui`: "Passphrase unlock screen" currently promises "a setup screen for
+ a host reporting no existing vault"; the host now supplies wizard steps and the
+ shared UI renders the wizard instead.
+- `desktop-bridge`: pairing gains a first-run entry point on both sides — the
+ desktop can enable the bridge and show a code from its wizard, the extension
+ can pair and pick its mode from its own, without either passing through
+ Settings.
+
+`desktop-app` and `chrome-extension` need no delta: neither spec fixes what the
+first-run screen offers, and no existing requirement of theirs changes behavior.
+
+## Impact
+
+- `packages/ui`: new wizard screens and a host-supplied step descriptor; the
+ existing `SetupView` becomes the create-a-passphrase step inside it.
+- `packages/ui/src/core/vault-service.ts` (or an adjacent port): the adopt-a-blob
+ operation and a description of which starting points the host supports —
+ extending the port means updating the Tauri, extension, and mock
+ implementations together.
+- `apps/twofau-app`: a Tauri command to adopt an imported blob before any vault
+ exists, and the wizard's bridge step reusing the existing enable + pair-code
+ commands.
+- `apps/twofau-extension`: first-run wizard in the popup, writing `mode` (and
+ pairing) before `createVaultService()` picks a backend; the optional host
+ permission is requested inside the connect step.
+- `packages/i18n`: new user-facing strings in the `ui` catalog, English source
+ plus the machine-translated locales.
+- No change to the vault format, the crypto layer, or the bridge wire protocol.
diff --git a/openspec/changes/add-first-run-setup-wizard/specs/desktop-bridge/spec.md b/openspec/changes/add-first-run-setup-wizard/specs/desktop-bridge/spec.md
new file mode 100644
index 0000000..09ac99e
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/specs/desktop-bridge/spec.md
@@ -0,0 +1,26 @@
+## ADDED Requirements
+
+### Requirement: Pairing has a first-run entry point on both sides
+
+The desktop SHALL be able to enable the bridge and show a pairing code from its
+first-run wizard, and the extension SHALL be able to request the optional host
+permission, submit a code, and select its mode from its own first-run wizard —
+neither side requiring a trip through Settings to pair a first browser. These
+entry points SHALL reuse the existing pairing rules unchanged: the same
+short-lived code, the same token bound to the extension origin, the same paired
+list and revocation.
+
+#### Scenario: Pairing from the two wizards
+
+- **WHEN** the desktop wizard shows a code and the extension wizard submits it
+- **THEN** a token is minted exactly as it would be from Settings, and the browser appears in the paired list
+
+#### Scenario: Expired code in the wizard
+
+- **WHEN** the code shown by the desktop wizard is submitted after its validity window
+- **THEN** pairing fails with `401`, no token is minted, and the extension wizard offers a retry
+
+#### Scenario: Bridge stays off when unused
+
+- **WHEN** the desktop wizard's connect step is skipped
+- **THEN** the bridge remains disabled and no port is listening, exactly as on an install that never opened the wizard's connect step
diff --git a/openspec/changes/add-first-run-setup-wizard/specs/onboarding/spec.md b/openspec/changes/add-first-run-setup-wizard/specs/onboarding/spec.md
new file mode 100644
index 0000000..8164d3f
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/specs/onboarding/spec.md
@@ -0,0 +1,151 @@
+## Purpose
+
+What a host puts in front of someone who has just installed 2FAU and has no
+vault yet: the starting points it offers (create, import, connect to the
+desktop), the order they run in, and what each one leaves behind.
+
+## ADDED Requirements
+
+### Requirement: First run offers every starting point the host supports
+
+When a host reports no existing vault, the UI SHALL present the wizard rather
+than a lone create-a-passphrase form. The wizard SHALL offer creating a new
+vault, importing an existing vault file, and — where the host can reach a vault
+elsewhere — connecting to it. A host SHALL declare which of these it supports,
+and the wizard SHALL render only the declared ones, never a choice that cannot
+complete on that host.
+
+#### Scenario: Desktop first launch
+
+- **WHEN** the desktop app launches with no vault at its vault path
+- **THEN** the wizard offers creating a new vault and importing a vault file
+
+#### Scenario: Extension first open
+
+- **WHEN** the extension popup opens with no vault in storage
+- **THEN** the wizard offers creating a new vault, importing a vault file, and connecting to the desktop app
+
+#### Scenario: Host that cannot import
+
+- **WHEN** a host declares no import support
+- **THEN** the import choice is not rendered and the remaining choices are still complete on their own
+
+### Requirement: Creating a new vault
+
+The create path SHALL take a passphrase and its confirmation, SHALL enforce the
+same minimum length the setup screen enforces today, and SHALL end with an empty
+vault sealed under that passphrase and the session unlocked.
+
+#### Scenario: Passphrase accepted
+
+- **WHEN** a passphrase of at least the minimum length is entered and confirmed
+- **THEN** an empty vault is created, the session is unlocked, and the account list is shown
+
+#### Scenario: Passphrase rejected
+
+- **WHEN** the passphrase is too short or the confirmation does not match
+- **THEN** the step reports it inline and no vault is created
+
+### Requirement: Importing adopts the file
+
+The import path SHALL take a vault file and the passphrase that opens it, and
+SHALL adopt that file as this device's vault: its accounts become the vault's
+accounts and its passphrase becomes this device's passphrase. The user SHALL NOT
+be asked to invent a second passphrase first. A failure SHALL leave the host with
+no vault, still on the wizard, rather than a half-created one.
+
+#### Scenario: Vault file from another device
+
+- **WHEN** a vault exported from another device is imported with its passphrase
+- **THEN** the same accounts are present, the session is unlocked, and that passphrase unlocks this device from then on
+
+#### Scenario: Wrong passphrase for the file
+
+- **WHEN** the passphrase does not open the chosen file
+- **THEN** the step shows the error, no vault exists, and the user can retry or pick a different starting point
+
+#### Scenario: Unreadable or foreign file
+
+- **WHEN** the chosen file is not a 2FAU vault blob
+- **THEN** the step reports it as an unusable file, distinctly from a wrong passphrase
+
+#### Scenario: Import cancelled
+
+- **WHEN** the user dismisses the file picker without choosing a file
+- **THEN** the wizard returns to its choices unchanged
+
+### Requirement: Connecting the extension to the desktop
+
+The extension's connect path SHALL request the optional host permission, pair
+with the desktop using a code shown by the desktop app, and have the user choose
+whether the desktop holds the only vault or this browser keeps a synced copy.
+The chosen mode SHALL be persisted before a backend is built, so the first screen
+after the wizard already talks to the chosen backend. Declining the permission,
+or failing to pair, SHALL leave the extension with no vault and no mode change,
+back on the wizard's choices.
+
+#### Scenario: Pairing succeeds
+
+- **WHEN** a valid pairing code is entered and a mode is chosen
+- **THEN** the mode is saved, the pairing token is stored, and the popup opens against the desktop vault
+
+#### Scenario: Permission declined
+
+- **WHEN** the user declines the loopback host permission
+- **THEN** no pairing is attempted, the mode stays unset, and the wizard explains that connecting needs that permission
+
+#### Scenario: Desktop not reachable
+
+- **WHEN** the desktop app is not running or its bridge is off
+- **THEN** the step says so and offers the other starting points, rather than leaving the extension in a mode it cannot serve
+
+#### Scenario: Browser keeps a copy
+
+- **WHEN** the user chooses to keep a synced copy in this browser
+- **THEN** the desktop vault's passphrase is what unlocks the browser copy, because both are the same sealed blob
+
+### Requirement: Desktop offers to connect a browser after the vault exists
+
+Once the desktop wizard has created or imported a vault, it SHALL offer one more
+optional step that enables the bridge and shows a pairing code. That step SHALL
+be skippable, and skipping it SHALL leave the bridge off and no port listening.
+The step SHALL NOT be presented as an alternative to having a vault.
+
+#### Scenario: User connects a browser
+
+- **WHEN** the user continues into the connect step
+- **THEN** the bridge is enabled and a pairing code is displayed for the extension to consume
+
+#### Scenario: User skips
+
+- **WHEN** the user skips the connect step
+- **THEN** the app proceeds to the account list, the bridge is off, nothing is listening, and the same setup remains available in Settings
+
+### Requirement: The wizard is first-run only
+
+The wizard SHALL appear only while the host reports no vault, and SHALL never
+stand between an existing vault and its unlock screen. It SHALL NOT be reachable
+from Settings; every choice it makes SHALL remain editable there afterwards. A
+wizard abandoned before a vault exists SHALL leave nothing behind, so the next
+launch starts it again from the choices. Once a vault exists the wizard is done:
+any step still outstanding SHALL be reachable in Settings instead.
+
+#### Scenario: Vault already exists
+
+- **WHEN** a host with a vault starts
+- **THEN** the unlock screen is shown and the wizard is not rendered
+
+#### Scenario: Abandoned before a vault exists
+
+- **WHEN** the app or popup is closed while still choosing, creating, or importing
+- **THEN** no vault, mode, or pairing is left behind and the next start shows the choices again
+
+#### Scenario: Abandoned after the vault exists
+
+- **WHEN** the desktop app is closed during the optional connect step
+- **THEN** the next launch goes to unlock, not back into the wizard, and the bridge setup waits in Settings
+
+#### Scenario: Changing a choice later
+
+- **WHEN** the user later wants a different mode, another import, or to pair a second browser
+- **THEN** they do it in Settings, which offers all of it without the wizard
diff --git a/openspec/changes/add-first-run-setup-wizard/specs/shared-ui/spec.md b/openspec/changes/add-first-run-setup-wizard/specs/shared-ui/spec.md
new file mode 100644
index 0000000..ffdb370
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/specs/shared-ui/spec.md
@@ -0,0 +1,27 @@
+## MODIFIED Requirements
+
+### Requirement: Passphrase unlock screen
+
+Because the cross-platform root of trust is a passphrase, the shared UI SHALL
+provide an unlock screen with a single secret field and an inline error caption.
+For a host reporting no existing vault, the shared UI SHALL render the first-run
+wizard, whose steps are driven by what the host declares it supports; the
+passphrase-creation form is one of those steps, not the whole first-run screen.
+The wizard SHALL stay host-agnostic: the steps that need host I/O (picking a
+file, requesting a permission, pairing) SHALL run through the port, not through
+host APIs imported into `@twofau/ui`.
+
+#### Scenario: Wrong passphrase
+
+- **WHEN** unlocking fails
+- **THEN** the unlock screen shows the error and the vault stays locked
+
+#### Scenario: No vault yet
+
+- **WHEN** the host reports no existing vault
+- **THEN** the wizard is rendered with the starting points that host declared, instead of the unlock screen
+
+#### Scenario: Building the UI package standalone
+
+- **WHEN** `@twofau/ui` is built or tested with only `MockVaultService`
+- **THEN** the wizard compiles and its steps are exercisable without any host present
diff --git a/openspec/changes/add-first-run-setup-wizard/tasks.md b/openspec/changes/add-first-run-setup-wizard/tasks.md
new file mode 100644
index 0000000..10b3b40
--- /dev/null
+++ b/openspec/changes/add-first-run-setup-wizard/tasks.md
@@ -0,0 +1,33 @@
+## 1. Shared UI: the wizard shell
+
+- [x] 1.1 Add the `SetupBackend` type in `packages/ui/src/core/` — optional `adopt?: ImportSpec` and `connect?: { screen: ReactNode; optional: boolean }`, with create implied — and thread it through `TwoFAUApp`/`RootView` as an optional prop. Verify `pnpm -r typecheck` passes with no host updated yet.
+- [x] 1.2 Add the wizard component that renders the choices present in the backend and routes to a step; render it from `RootView` in place of `SetupView` when `needsSetup` is true. Verify a Vitest case: with no backend passed, the wizard shows only the create choice and behaves exactly as today's setup screen.
+- [x] 1.3 Move the existing `SetupView` form in as the create step, unchanged in validation and in its `unlock()` call. Verify `setup-view.test.tsx` still passes (adapted to its new mount point) and a too-short passphrase still creates nothing.
+- [x] 1.4 Add the import step over `ImportSpec` (both `native` and `file` kinds) with distinct inline errors for a wrong passphrase and an unusable file. Verify Vitest cases for success, wrong passphrase, unreadable file, and a cancelled picker — each asserting no vault is left behind on failure.
+- [x] 1.5 Render `connect.screen` as a step and handle its skip. Verify a Vitest case that the wizard finishes when the step is skipped and reports completion when it is not.
+- [x] 1.6 Wrap every new user-facing string in `t()` and add the English entries to the `ui` catalog. Verify `pnpm -r test` passes and no literal string reaches the rendered output untranslated.
+
+## 2. Desktop: adopt an imported vault
+
+- [x] 2.1 Add an `AppVault` method that adopts a blob when no vault exists: open it with the supplied passphrase, write those bytes to the vault path, unlock. Verify a Rust test that adopting a vault exported elsewhere yields its accounts and that its own passphrase unlocks a reopened `AppVault`.
+- [x] 2.2 Make adoption refuse when a vault already exists, with a distinct error. Verify a Rust test asserting the existing vault is untouched.
+- [x] 2.3 Expose the Tauri command (file picker + adopt, guarded by `DialogGuard` like the other dialog commands) and wire it as the desktop's `adopt: { kind: "native" }`. Verify `cargo test` passes and the popup does not hide while the picker is up (interactive check — report it as such).
+
+## 3. Desktop: the wizard
+
+- [x] 3.1 Supply `setupBackend` from the desktop host with the native adopt spec and a connect step reusing the existing bridge-enable and pairing-code commands. Verify the wizard on a fresh profile offers create and import, and the connect step appears only after the vault exists.
+- [x] 3.2 Verify skipping the connect step leaves the bridge disabled and nothing listening (check no socket on the configured port after the wizard closes).
+- [ ] 3.3 Verify a human run on a fresh profile: create → list, import → list, connect → code shown and consumable. Report the tray/popup parts as human-verified, never as headlessly confirmed.
+
+## 4. Extension: adopt and connect before a backend exists
+
+- [x] 4.1 Add the adopt path to the extension vault layer: verify the uploaded blob opens with the passphrase, write it as the first generation through the repo, put the derived key in session storage. Verify a Vitest case that a desktop-exported blob adopts and lists its accounts.
+- [x] 4.2 Change the popup bootstrap to detect no vault and no pairing token and render the wizard without calling `createVaultService()`. Verify an existing install (vault present) never reaches the wizard, and that a fresh one does not render `DesktopUnavailable`.
+- [x] 4.3 Build the connect step: request the optional `http://127.0.0.1/*` permission, submit the pairing code, choose the mode, and persist the mode before any backend is built. Verify Vitest cases for a declined permission, an unreachable desktop, and an expired code — each leaving no vault and no mode change.
+- [x] 4.4 Re-bootstrap the popup once a step completes, so the first screen after the wizard talks to the chosen backend. Verify by asserting the service built after a `client`-mode wizard run is the HTTP-backed one.
+
+## 5. Verification
+
+- [x] 5.1 Run the full gate: `cargo fmt --check && cargo clippy --all-targets -D warnings && cargo test && pnpm -r test && pnpm -r typecheck`. Note any pre-existing lint drift on `main` separately from this change.
+- [ ] 5.2 Verify the end-to-end pairing story across both wizards on a real machine: desktop wizard creates a vault and shows a code, extension wizard consumes it, popup opens against the desktop vault.
+- [x] 5.3 Verify the untouched paths still work: Settings → import still merges into an unlocked vault, Settings → Sync still changes mode and re-pairs, and an existing install upgrades straight to its unlock screen.
diff --git a/openspec/changes/fix-release-pr-flow/.openspec.yaml b/openspec/changes/fix-release-pr-flow/.openspec.yaml
new file mode 100644
index 0000000..eaa6b1c
--- /dev/null
+++ b/openspec/changes/fix-release-pr-flow/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-19
diff --git a/openspec/changes/fix-release-pr-flow/design.md b/openspec/changes/fix-release-pr-flow/design.md
new file mode 100644
index 0000000..7480e57
--- /dev/null
+++ b/openspec/changes/fix-release-pr-flow/design.md
@@ -0,0 +1,79 @@
+## Context
+
+See proposal.md — Why. Two facts shape the approach:
+
+- Knope's `CreatePullRequest` step reads the repository's current branch as the
+ PR head. Nothing in the step takes a head parameter, so the only way to change
+ the head is to change the branch Knope is standing on when the step runs. The
+ workflow triggers on a push to `main`, so without intervention head = base.
+- `PrepareRelease` stages its edits (a `git add` of the versioned files, the
+ changelog, and the deleted change files) but does not commit or push them.
+
+Knope documents exactly this shape in its "preview releases with pull requests"
+recipe; the repo's config had the two `CreatePullRequest`/`PrepareRelease` steps
+without the surrounding Git commands.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- The prepare workflow reaches the point of an open, updatable release PR.
+- The PR is mergeable: every required check on it can pass unattended.
+
+**Non-Goals:**
+
+- The `RELEASE_TOKEN` secret. Both workflows already gate on it and warn when
+ absent; setting it is an account action outside this change.
+- Any change to `knope release`, the tag path, the `tip` prerelease, or asset
+ uploads.
+
+## Decisions
+
+**A single `release` branch, force-pushed.** `git switch -c release` →
+`PrepareRelease` → commit → `git push --force --set-upstream origin release`.
+Alternatives: a per-version branch (`release/0.1.1`) leaves dead branches behind
+and orphans the open PR when the version changes mid-cycle; a branch derived from
+the commit SHA opens a new PR per push. One reused branch keeps exactly one
+release PR open, which is the behavior the spec asks for. Force-push is safe
+because the branch holds no history worth keeping — it is regenerated from `main`
+on every run.
+
+**Commit message `chore: Release $version`.** It matches the PR title and the
+`startsWith(head_commit.message, 'chore: Release')` guard that stops the prepare
+workflow from preparing a release of the release.
+
+**Git identity configured in the workflow, not in `knope.toml`.** The commit
+happens inside Knope's step list, but `user.name`/`user.email` are environment
+concerns; a CI-only step keeps `knope.toml` runnable on a developer machine with
+their own identity.
+
+**The changeset check exits 0 rather than skipping the job.** A job-level `if:`
+would report the check as skipped; a step that exits 0 reports success. Success
+is unambiguous under branch protection, so the exemption goes next to the
+existing `skip-changeset` label check, sharing its early-exit shape.
+
+**Exempt by head branch, not by PR title or author.** The branch name is set by
+our own config one file away; a title is editable by anyone with write access and
+an author depends on which PAT `RELEASE_TOKEN` happens to hold.
+
+## Risks / Trade-offs
+
+- Anyone with write access can push a branch named `release` and bypass the
+ change-file check → the check is a release-notes hygiene gate, not a security
+ control, and pushing to `release` means fighting the next force-push.
+- A long-lived release PR accumulates force-pushes, so review comments on old
+ diffs go stale → the PR is machine-generated and read for its changelog, not
+ line-reviewed.
+- `git switch -c release` fails if a local `release` branch already exists. On a
+ fresh runner checkout it never does; running `knope prepare-release` locally a
+ second time will fail this way → run it in CI, or delete the local branch.
+- Verified only as far as `knope prepare-release --dry-run` locally. The dry run
+ does not execute the `git switch`, so it still prints the head as `main`; the
+ real head can only be confirmed by a run on `main`.
+
+## Migration Plan
+
+Merge to `main`; the push triggers the prepare workflow, which either opens the
+release PR or no-ops. Rollback is reverting the three files — the pipeline
+returns to its current state of never producing a PR. A stale `release` branch
+can be deleted afterwards without consequence.
diff --git a/openspec/changes/fix-release-pr-flow/proposal.md b/openspec/changes/fix-release-pr-flow/proposal.md
new file mode 100644
index 0000000..b4c6cb0
--- /dev/null
+++ b/openspec/changes/fix-release-pr-flow/proposal.md
@@ -0,0 +1,43 @@
+## Why
+
+The release PR has never been created. `knope prepare-release` runs on a push to
+`main` and its `CreatePullRequest` step opens the PR **from the current branch**,
+which is `main` — so GitHub rejects it with `422 Validation Failed: No commits
+between main and main` and the whole pipeline stalls before a single release.
+Even once the PR opens, the `Changeset` check would fail it: `prepare-release`
+consumes the change files in `.changeset/`, so the release PR adds none and the
+gate that every PR into `main` must add one blocks the only PR that never can.
+
+## What Changes
+
+- `knope prepare-release` creates a disposable `release` branch, commits the
+ version bump + changelog there, force-pushes it, and opens the PR from
+ `release` → `main` (the branch mechanics the release-pipeline spec already
+ describes but the config never implemented).
+- The `Prepare release` job configures a Git identity, since the workflow now
+ commits inside Knope's steps.
+- The `Changeset` check exempts PRs whose head branch is `release`, alongside the
+ existing `skip-changeset` label escape hatch.
+
+## Capabilities
+
+### New Capabilities
+
+
+
+### Modified Capabilities
+
+- `release-pipeline`: the "Release PR is the single human action" requirement
+ gains the branch mechanics that make the PR creatable (head ≠ base, committed
+ and pushed by the workflow), plus a new requirement that the change-file check
+ exempts the release PR.
+
+## Impact
+
+- `knope.toml` — `prepare-release` workflow gains `Command` steps around
+ `PrepareRelease`.
+- `.github/workflows/prepare-release.yml` — adds a Git identity step.
+- `.github/workflows/changeset.yml` — adds the `release` head-branch exemption.
+- A `release` branch now exists on the remote, rewritten on every push to `main`.
+- No product code, no crates, no packages. Requires the `RELEASE_TOKEN` secret to
+ be set before any of this runs (still outstanding).
diff --git a/openspec/changes/fix-release-pr-flow/specs/release-pipeline/spec.md b/openspec/changes/fix-release-pr-flow/specs/release-pipeline/spec.md
new file mode 100644
index 0000000..4e26a00
--- /dev/null
+++ b/openspec/changes/fix-release-pr-flow/specs/release-pipeline/spec.md
@@ -0,0 +1,47 @@
+## MODIFIED Requirements
+
+### Requirement: Release PR is the single human action
+
+A push to `main` SHALL run `knope prepare-release`, which bumps the versioned
+files, writes the changelog, consumes change files, and opens or updates a
+"chore: Release x.y.z" pull request from the `release` branch. The prepared
+commit SHALL live on the `release` branch and be pushed there before the pull
+request is opened, so the pull request's head and base are never the same
+branch. The `release` branch SHALL be disposable: each run overwrites it with the
+current preparation. The workflow SHALL skip its own release commits so it does
+not loop, and SHALL check out full history so commits since the last tag can be
+read.
+
+#### Scenario: Release PR merged
+
+- **WHEN** the release PR merges to `main`
+- **THEN** the version bumps and changelog land, and the tag, release, and asset uploads follow with no further human action
+
+#### Scenario: Preparation runs from main
+
+- **WHEN** `knope prepare-release` runs on a push to `main` and there is something to release
+- **THEN** the bump is committed to the `release` branch and the pull request is created from `release` into `main`, never from `main` into `main`
+
+#### Scenario: A release PR is already open
+
+- **WHEN** a later push to `main` prepares a release while the previous release PR is still open
+- **THEN** the `release` branch is overwritten with the new preparation and the existing pull request's title, body, and diff are updated in place
+
+## ADDED Requirements
+
+### Requirement: The change-file check exempts the release PR
+
+The check that requires every pull request into `main` to add a change file in
+`.changeset/` SHALL NOT apply to the Knope release pull request, which consumes
+change files rather than adding one. The check SHALL report success for that pull
+request so branch protection does not block the merge.
+
+#### Scenario: Release PR opened
+
+- **WHEN** the change-file check runs on the pull request from the `release` branch
+- **THEN** it passes without requiring a change file and without demanding a manual label
+
+#### Scenario: Ordinary PR without a change file
+
+- **WHEN** the check runs on any other pull request that adds no change file and carries no skip label
+- **THEN** it still fails, unchanged by this exemption
diff --git a/openspec/changes/fix-release-pr-flow/tasks.md b/openspec/changes/fix-release-pr-flow/tasks.md
new file mode 100644
index 0000000..437ca51
--- /dev/null
+++ b/openspec/changes/fix-release-pr-flow/tasks.md
@@ -0,0 +1,17 @@
+## 1. Release PR branch mechanics
+
+- [x] 1.1 In `knope.toml`, wrap the `prepare-release` workflow's `PrepareRelease` step with `Command` steps: `git switch -c release` before it, then `git commit -m "chore: Release $version"` (with `variables = { "$version" = "Version" }`) and `git push --force --set-upstream origin release` after it, before `CreatePullRequest`. Verify `knope prepare-release --dry-run` lists the three commands in that order around the file edits.
+- [x] 1.2 In `.github/workflows/prepare-release.yml`, add a Git identity step after checkout (gated on the same `RELEASE_TOKEN` condition as the surrounding steps) so the commit in 1.1 has an author. Verify the step's `if:` matches the neighbouring steps and the YAML parses (`openspec` aside, e.g. `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/prepare-release.yml'))"`).
+- [x] 1.3 Confirm the commit message from 1.1 still satisfies the workflow's `!startsWith(github.event.head_commit.message, 'chore: Release')` loop guard, so merging the release PR does not prepare a release of the release.
+
+## 2. Change-file check exemption
+
+- [x] 2.1 In `.github/workflows/changeset.yml`, pass the PR head branch into the step's `env` and exit 0 early when it is `release`, next to the existing `skip-changeset` label check. Verify the early exit runs before the `.changeset/` diff so no comment is posted on the release PR.
+- [x] 2.2 Verify an ordinary PR is unaffected: the diff-based check and its sticky comment still run and still fail when no change file is added and no skip label is present.
+- [x] 2.3 Update the workflow's header comment to document the new escape hatch alongside the label.
+
+## 3. End-to-end verification
+
+- [x] 3.1 Run `knope prepare-release --dry-run` locally and verify it computes the next version, stages every versioned file plus `CHANGELOG.md`, deletes the change files, and reports the commit/push commands (the dry run does not execute `git switch`, so it will still print the head as `main`).
+- [ ] 3.2 After merging to `main`, verify the `Prepare release` run succeeds, a `release` branch exists on the remote, and a "chore: Release x.y.z" PR is open from `release` into `main`. If `RELEASE_TOKEN` is still unset, verify instead that the job skips with its warning rather than failing.
+- [ ] 3.3 Verify the `Changeset` check on that release PR reports success (not skipped) and that the PR is mergeable.
diff --git a/packages/i18n/locales/de/ui.json b/packages/i18n/locales/de/ui.json
index 10b07b6..cb965b5 100644
--- a/packages/i18n/locales/de/ui.json
+++ b/packages/i18n/locales/de/ui.json
@@ -1,12 +1,22 @@
{
- "_meta": { "review": "machine" },
- "{count} accounts": { "one": "1 Konto", "other": "{count} Konten" },
- "{count} minutes": { "one": "1 Minute", "other": "{count} Minuten" },
+ "_meta": {
+ "review": "machine"
+ },
+ "{count} accounts": {
+ "one": "1 Konto",
+ "other": "{count} Konten"
+ },
+ "{count} minutes": {
+ "one": "1 Minute",
+ "other": "{count} Minuten"
+ },
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "Über",
"Account QR code": "Konto-QR-Code",
"Add account": "Konto hinzufügen",
"Add from clipboard": "Aus Zwischenablage hinzufügen",
"Auto-Lock": "Automatische Sperre",
+ "Back": "",
"Bulk": "Mehrere",
"Can’t reach the desktop vault": "Desktop-Tresor nicht erreichbar",
"Cancel": "Abbrechen",
@@ -16,8 +26,10 @@
"Checking…": "Wird geprüft…",
"Choose File & Import": "Datei wählen & importieren",
"Clipboard": "Zwischenablage",
+ "Close": "",
"Confirm new password": "Neues Passwort bestätigen",
"Confirm passphrase": "Passphrase bestätigen",
+ "Connect your browser": "",
"Copied": "Kopiert",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "{issuer}-Code für {label} kopieren. Noch {seconds} Sekunden",
"Copy an account's code with this modifier plus its number (1–5).": "Kopiere den Code eines Kontos mit dieser Zusatztaste und seiner Nummer (1–5).",
@@ -31,6 +43,7 @@
"Could not save: {error}": "Speichern fehlgeschlagen: {error}",
"Could not start: {error}": "Start fehlgeschlagen: {error}",
"Could not unlock: {error}": "Entsperren fehlgeschlagen: {error}",
+ "Create a new vault": "",
"Create a passphrase": "Passphrase erstellen",
"Create vault": "Tresor erstellen",
"Current": "Aktuell",
@@ -47,6 +60,7 @@
"Drag to reorder": "Zum Neuanordnen ziehen",
"Edit {issuer} account": "{issuer}-Konto bearbeiten",
"Edit account": "Konto bearbeiten",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "Passphrase zum Entsperren eingeben",
"Export Vault": "Tresor exportieren",
"Feedback": "Feedback",
@@ -57,15 +71,25 @@
"Hotkeys": "Tastenkürzel",
"How long the app can sit idle before it locks and asks for your password again.": "Wie lange die App untätig sein kann, bevor sie sperrt und erneut nach deinem Passwort fragt.",
"Import": "Importieren",
+ "Import a vault file": "",
"Import accounts": "Konten importieren",
"Import many, or a Google Authenticator export": "Mehrere importieren oder einen Google-Authenticator-Export",
"Import Vault": "Tresor importieren",
- "Imported {added} of {count} accounts.": { "one": "{added} von {count} Konto importiert.", "other": "{added} von {count} Konten importiert." },
- "Imported. The vault now holds {count} accounts.": { "one": "Importiert. Der Tresor enthält jetzt 1 Konto.", "other": "Importiert. Der Tresor enthält jetzt {count} Konten." },
+ "Imported {added} of {count} accounts.": {
+ "one": "{added} von {count} Konto importiert.",
+ "other": "{added} von {count} Konten importiert."
+ },
+ "Imported. The vault now holds {count} accounts.": {
+ "one": "Importiert. Der Tresor enthält jetzt 1 Konto.",
+ "other": "Importiert. Der Tresor enthält jetzt {count} Konten."
+ },
"Importing…": "Wird importiert…",
"Issuer": "Aussteller",
"Issuer (e.g. GitHub)": "Aussteller (z. B. GitHub)",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "Sie verschlüsselt deine Konten auf diesem Gerät und kann nicht wiederhergestellt werden — vergiss sie nicht.",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "Bezeichnung",
"Label (e.g. me@x.com)": "Bezeichnung (z. B. ich@x.com)",
"Language": "Sprache",
@@ -83,16 +107,22 @@
"No QR code found in that image.": "In diesem Bild wurde kein QR-Code gefunden.",
"Not paired yet": "Noch nicht gekoppelt",
"Not set": "Nicht festgelegt",
+ "Nothing is stored in this browser.": "",
"Off": "Aus",
"On": "Ein",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "Beim Anmelden öffnen",
"Open Settings": "Einstellungen öffnen",
+ "Pair": "",
"Pair with desktop": "Mit Desktop koppeln",
"Paired — desktop connected": "Gekoppelt – Desktop verbunden",
"Paired — desktop unreachable": "Gekoppelt – Desktop nicht erreichbar",
"Paired. Open the popup and unlock with your desktop passphrase.": "Gekoppelt. Öffne das Popup und entsperre mit deiner Desktop-Passphrase.",
+ "Pairing code": "",
"Pairing code from the desktop app": "Kopplungscode aus der Desktop-App",
+ "Pairing…": "",
"Passphrase": "Passphrase",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "Passphrasen stimmen nicht überein.",
"Password changed.": "Passwort geändert.",
"Password of the file": "Passwort der Datei",
@@ -101,6 +131,7 @@
"Paste an otpauth:// link or Base32 secret": "Füge einen otpauth://-Link oder ein Base32-Secret ein",
"Paste from:": "Einfügen aus:",
"Permission to reach the desktop app was declined.": "Die Berechtigung, die Desktop-App zu erreichen, wurde abgelehnt.",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "Port",
"Preferences": "Einstellungen",
"Press keys…": "Tasten drücken…",
@@ -117,26 +148,37 @@
"Scan to add on another device.": "Scanne, um auf einem anderen Gerät hinzuzufügen.",
"Search": "Suchen",
"Secret (Base32)": "Secret (Base32)",
+ "Set up 2FAU": "",
"Settings": "Einstellungen",
"Show 2FAU": "2FAU anzeigen",
"Show QR code": "QR-Code anzeigen",
+ "Skip for now": "",
"Source Code": "Quellcode",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "Synchronisierung",
"Sync across my Chrome profile": "Über mein Chrome-Profil synchronisieren",
"Sync with desktop": "Mit Desktop synchronisieren",
"Tap + to add one": "Tippe auf +, um eines hinzuzufügen",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "Die Desktop-App ist erreichbar, hat diesen Browser aber abgelehnt. Koppel ihn erneut oder wechsle den Modus in den Einstellungen.",
"The shortcut that opens the 2FAU popup from anywhere.": "Das Tastenkürzel, das das 2FAU-Popup von überall öffnet.",
"This browser": "Dieser Browser",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "Dieser Browser behält seinen eigenen Tresor und synchronisiert ihn mit der Desktop-App, wenn sie läuft.",
"This browser keeps its own vault.": "Dieser Browser behält seinen eigenen Tresor.",
"Translations are community-contributed and may be incomplete.": "Übersetzungen stammen von der Community und können unvollständig sein.",
+ "Turn on the bridge": "",
"Unlock": "Entsperren",
"Unlocking…": "Wird entsperrt…",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "Verwende mindestens {count} Zeichen.",
"Use at least 8 characters.": "Verwende mindestens 8 Zeichen.",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "{used} KB von {quota} KB belegt ({percent}%).",
"Vault": "Tresor",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "Die Tresore liegen in der Desktop-App; dieser Browser ist ein Client.",
"Version": "Version",
"Where the vault lives": "Wo der Tresor liegt",
diff --git a/packages/i18n/locales/en/ui.json b/packages/i18n/locales/en/ui.json
index 1841ec6..fcf1ecf 100644
--- a/packages/i18n/locales/en/ui.json
+++ b/packages/i18n/locales/en/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "{count} accounts",
"{count} minutes": "{count} minutes",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.",
"About": "About",
"Account QR code": "Account QR code",
"Add account": "Add account",
"Add from clipboard": "Add from clipboard",
"Auto-Lock": "Auto-Lock",
+ "Back": "Back",
"Bulk": "Bulk",
"Can’t reach the desktop vault": "Can’t reach the desktop vault",
"Cancel": "Cancel",
@@ -15,8 +17,10 @@
"Checking…": "Checking…",
"Choose File & Import": "Choose File & Import",
"Clipboard": "Clipboard",
+ "Close": "Close",
"Confirm new password": "Confirm new password",
"Confirm passphrase": "Confirm passphrase",
+ "Connect your browser": "Connect your browser",
"Copied": "Copied",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "Copy {issuer} code for {label}. {seconds} seconds remaining",
"Copy an account's code with this modifier plus its number (1–5).": "Copy an account's code with this modifier plus its number (1–5).",
@@ -30,6 +34,7 @@
"Could not save: {error}": "Could not save: {error}",
"Could not start: {error}": "Could not start: {error}",
"Could not unlock: {error}": "Could not unlock: {error}",
+ "Create a new vault": "Create a new vault",
"Create a passphrase": "Create a passphrase",
"Create vault": "Create vault",
"Current": "Current",
@@ -46,6 +51,7 @@
"Drag to reorder": "Drag to reorder",
"Edit {issuer} account": "Edit {issuer} account",
"Edit account": "Edit account",
+ "Enter this code in the extension. It expires in about two minutes.": "Enter this code in the extension. It expires in about two minutes.",
"Enter your passphrase to unlock": "Enter your passphrase to unlock",
"Export Vault": "Export Vault",
"Feedback": "Feedback",
@@ -56,6 +62,7 @@
"Hotkeys": "Hotkeys",
"How long the app can sit idle before it locks and asks for your password again.": "How long the app can sit idle before it locks and asks for your password again.",
"Import": "Import",
+ "Import a vault file": "Import a vault file",
"Import accounts": "Import accounts",
"Import many, or a Google Authenticator export": "Import many, or a Google Authenticator export",
"Import Vault": "Import Vault",
@@ -65,6 +72,9 @@
"Issuer": "Issuer",
"Issuer (e.g. GitHub)": "Issuer (e.g. GitHub)",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "It encrypts your accounts on this device and can’t be recovered — don’t forget it.",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "Its accounts become this device’s vault, and its passphrase unlocks it from now on.",
+ "Keep a copy here, unlocked by the same passphrase.": "Keep a copy here, unlocked by the same passphrase.",
+ "Keep the vault in 2FAU on this computer.": "Keep the vault in 2FAU on this computer.",
"Label": "Label",
"Label (e.g. me@x.com)": "Label (e.g. me@x.com)",
"Language": "Language",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "No QR code found in that image.",
"Not paired yet": "Not paired yet",
"Not set": "Not set",
+ "Nothing is stored in this browser.": "Nothing is stored in this browser.",
"Off": "Off",
"On": "On",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.",
"Open at Login": "Open at Login",
"Open Settings": "Open Settings",
+ "Pair": "Pair",
"Pair with desktop": "Pair with desktop",
"Paired — desktop connected": "Paired — desktop connected",
"Paired — desktop unreachable": "Paired — desktop unreachable",
"Paired. Open the popup and unlock with your desktop passphrase.": "Paired. Open the popup and unlock with your desktop passphrase.",
+ "Pairing code": "Pairing code",
"Pairing code from the desktop app": "Pairing code from the desktop app",
+ "Pairing…": "Pairing…",
"Passphrase": "Passphrase",
+ "Passphrase of the file": "Passphrase of the file",
"Passphrases don’t match.": "Passphrases don’t match.",
"Password changed.": "Password changed.",
"Password of the file": "Password of the file",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "Paste an otpauth:// link or Base32 secret",
"Paste from:": "Paste from:",
"Permission to reach the desktop app was declined.": "Permission to reach the desktop app was declined.",
+ "Pick a passphrase and start with an empty vault.": "Pick a passphrase and start with an empty vault.",
"Port": "Port",
"Preferences": "Preferences",
"Press keys…": "Press keys…",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "Scan to add on another device.",
"Search": "Search",
"Secret (Base32)": "Secret (Base32)",
+ "Set up 2FAU": "Set up 2FAU",
"Settings": "Settings",
"Show 2FAU": "Show 2FAU",
"Show QR code": "Show QR code",
+ "Skip for now": "Skip for now",
"Source Code": "Source Code",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "Start a new vault, bring one you already have, or use the one in your desktop app.",
+ "Starting…": "Starting…",
"Sync": "Sync",
"Sync across my Chrome profile": "Sync across my Chrome profile",
"Sync with desktop": "Sync with desktop",
"Tap + to add one": "Tap + to add one",
+ "That file isn’t a 2FAU vault.": "That file isn’t a 2FAU vault.",
+ "That passphrase doesn’t open this file.": "That passphrase doesn’t open this file.",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.",
"The shortcut that opens the 2FAU popup from anywhere.": "The shortcut that opens the 2FAU popup from anywhere.",
"This browser": "This browser",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "This browser keeps its own vault and syncs it with the desktop app when it's running.",
"This browser keeps its own vault.": "This browser keeps its own vault.",
"Translations are community-contributed and may be incomplete.": "Translations are community-contributed and may be incomplete.",
+ "Turn on the bridge": "Turn on the bridge",
"Unlock": "Unlock",
"Unlocking…": "Unlocking…",
+ "Use a vault you exported from another device.": "Use a vault you exported from another device.",
"Use at least {count} characters.": "Use at least {count} characters.",
"Use at least 8 characters.": "Use at least 8 characters.",
+ "Use my desktop app": "Use my desktop app",
"Using {used} KB of {quota} KB ({percent}%).": "Using {used} KB of {quota} KB ({percent}%).",
"Vault": "Vault",
+ "Vault file": "Vault file",
"Vaults live in the desktop app; this browser is a client.": "Vaults live in the desktop app; this browser is a client.",
"Version": "Version",
"Where the vault lives": "Where the vault lives",
diff --git a/packages/i18n/locales/es/ui.json b/packages/i18n/locales/es/ui.json
index 5332497..28546d7 100644
--- a/packages/i18n/locales/es/ui.json
+++ b/packages/i18n/locales/es/ui.json
@@ -1,12 +1,22 @@
{
- "_meta": { "review": "machine" },
- "{count} accounts": { "one": "1 cuenta", "other": "{count} cuentas" },
- "{count} minutes": { "one": "1 minuto", "other": "{count} minutos" },
+ "_meta": {
+ "review": "machine"
+ },
+ "{count} accounts": {
+ "one": "1 cuenta",
+ "other": "{count} cuentas"
+ },
+ "{count} minutes": {
+ "one": "1 minuto",
+ "other": "{count} minutos"
+ },
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "Acerca de",
"Account QR code": "Código QR de la cuenta",
"Add account": "Añadir cuenta",
"Add from clipboard": "Añadir desde el portapapeles",
"Auto-Lock": "Bloqueo automático",
+ "Back": "",
"Bulk": "Varias",
"Can’t reach the desktop vault": "No se puede acceder al almacén de escritorio",
"Cancel": "Cancelar",
@@ -16,8 +26,10 @@
"Checking…": "Comprobando…",
"Choose File & Import": "Elegir archivo e importar",
"Clipboard": "Portapapeles",
+ "Close": "",
"Confirm new password": "Confirmar la nueva contraseña",
"Confirm passphrase": "Confirmar la frase de contraseña",
+ "Connect your browser": "",
"Copied": "Copiado",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "Copiar el código de {issuer} para {label}. Quedan {seconds} segundos",
"Copy an account's code with this modifier plus its number (1–5).": "Copia el código de una cuenta con este modificador más su número (1–5).",
@@ -31,6 +43,7 @@
"Could not save: {error}": "No se pudo guardar: {error}",
"Could not start: {error}": "No se pudo iniciar: {error}",
"Could not unlock: {error}": "No se pudo desbloquear: {error}",
+ "Create a new vault": "",
"Create a passphrase": "Crea una frase de contraseña",
"Create vault": "Crear almacén",
"Current": "Actual",
@@ -47,6 +60,7 @@
"Drag to reorder": "Arrastra para reordenar",
"Edit {issuer} account": "Editar la cuenta de {issuer}",
"Edit account": "Editar cuenta",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "Introduce tu frase de contraseña para desbloquear",
"Export Vault": "Exportar almacén",
"Feedback": "Comentarios",
@@ -57,15 +71,25 @@
"Hotkeys": "Atajos de teclado",
"How long the app can sit idle before it locks and asks for your password again.": "Cuánto tiempo puede estar inactiva la app antes de bloquearse y volver a pedir tu contraseña.",
"Import": "Importar",
+ "Import a vault file": "",
"Import accounts": "Importar cuentas",
"Import many, or a Google Authenticator export": "Importa varias, o una exportación de Google Authenticator",
"Import Vault": "Importar almacén",
- "Imported {added} of {count} accounts.": { "one": "Se importó {added} de {count} cuenta.", "other": "Se importaron {added} de {count} cuentas." },
- "Imported. The vault now holds {count} accounts.": { "one": "Importado. El almacén ahora tiene 1 cuenta.", "other": "Importado. El almacén ahora tiene {count} cuentas." },
+ "Imported {added} of {count} accounts.": {
+ "one": "Se importó {added} de {count} cuenta.",
+ "other": "Se importaron {added} de {count} cuentas."
+ },
+ "Imported. The vault now holds {count} accounts.": {
+ "one": "Importado. El almacén ahora tiene 1 cuenta.",
+ "other": "Importado. El almacén ahora tiene {count} cuentas."
+ },
"Importing…": "Importando…",
"Issuer": "Emisor",
"Issuer (e.g. GitHub)": "Emisor (p. ej., GitHub)",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "Cifra tus cuentas en este dispositivo y no se puede recuperar: no la olvides.",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "Etiqueta",
"Label (e.g. me@x.com)": "Etiqueta (p. ej., yo@x.com)",
"Language": "Idioma",
@@ -83,16 +107,22 @@
"No QR code found in that image.": "No se encontró ningún código QR en esa imagen.",
"Not paired yet": "Aún no emparejado",
"Not set": "Sin establecer",
+ "Nothing is stored in this browser.": "",
"Off": "Desactivado",
"On": "Activado",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "Abrir al iniciar sesión",
"Open Settings": "Abrir ajustes",
+ "Pair": "",
"Pair with desktop": "Emparejar con el escritorio",
"Paired — desktop connected": "Emparejado: escritorio conectado",
"Paired — desktop unreachable": "Emparejado: escritorio inaccesible",
"Paired. Open the popup and unlock with your desktop passphrase.": "Emparejado. Abre la ventana emergente y desbloquea con la frase de contraseña de tu escritorio.",
+ "Pairing code": "",
"Pairing code from the desktop app": "Código de emparejamiento de la app de escritorio",
+ "Pairing…": "",
"Passphrase": "Frase de contraseña",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "Las frases de contraseña no coinciden.",
"Password changed.": "Contraseña cambiada.",
"Password of the file": "Contraseña del archivo",
@@ -101,6 +131,7 @@
"Paste an otpauth:// link or Base32 secret": "Pega un enlace otpauth:// o un secreto Base32",
"Paste from:": "Pegar desde:",
"Permission to reach the desktop app was declined.": "Se denegó el permiso para acceder a la app de escritorio.",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "Puerto",
"Preferences": "Preferencias",
"Press keys…": "Pulsa las teclas…",
@@ -117,26 +148,37 @@
"Scan to add on another device.": "Escanea para añadir en otro dispositivo.",
"Search": "Buscar",
"Secret (Base32)": "Secreto (Base32)",
+ "Set up 2FAU": "",
"Settings": "Ajustes",
"Show 2FAU": "Mostrar 2FAU",
"Show QR code": "Mostrar código QR",
+ "Skip for now": "",
"Source Code": "Código fuente",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "Sincronización",
"Sync across my Chrome profile": "Sincronizar en mi perfil de Chrome",
"Sync with desktop": "Sincronizar con el escritorio",
"Tap + to add one": "Toca + para añadir una",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "La app de escritorio es accesible pero rechazó este navegador. Vuelve a emparejarlo o cambia de modo en Ajustes.",
"The shortcut that opens the 2FAU popup from anywhere.": "El atajo que abre la ventana emergente de 2FAU desde cualquier lugar.",
"This browser": "Este navegador",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "Este navegador mantiene su propio almacén y lo sincroniza con la app de escritorio cuando está en ejecución.",
"This browser keeps its own vault.": "Este navegador mantiene su propio almacén.",
"Translations are community-contributed and may be incomplete.": "Las traducciones las aporta la comunidad y pueden estar incompletas.",
+ "Turn on the bridge": "",
"Unlock": "Desbloquear",
"Unlocking…": "Desbloqueando…",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "Usa al menos {count} caracteres.",
"Use at least 8 characters.": "Usa al menos 8 caracteres.",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "Usando {used} KB de {quota} KB ({percent}%).",
"Vault": "Almacén",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "Los almacenes residen en la app de escritorio; este navegador es un cliente.",
"Version": "Versión",
"Where the vault lives": "Dónde reside el almacén",
diff --git a/packages/i18n/locales/fr/ui.json b/packages/i18n/locales/fr/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/fr/ui.json
+++ b/packages/i18n/locales/fr/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/it/ui.json b/packages/i18n/locales/it/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/it/ui.json
+++ b/packages/i18n/locales/it/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/ja/ui.json b/packages/i18n/locales/ja/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/ja/ui.json
+++ b/packages/i18n/locales/ja/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/ko/ui.json b/packages/i18n/locales/ko/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/ko/ui.json
+++ b/packages/i18n/locales/ko/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/pl/ui.json b/packages/i18n/locales/pl/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/pl/ui.json
+++ b/packages/i18n/locales/pl/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/pt-BR/ui.json b/packages/i18n/locales/pt-BR/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/pt-BR/ui.json
+++ b/packages/i18n/locales/pt-BR/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/ru/ui.json b/packages/i18n/locales/ru/ui.json
index c1f4ddd..5972876 100644
--- a/packages/i18n/locales/ru/ui.json
+++ b/packages/i18n/locales/ru/ui.json
@@ -1,12 +1,26 @@
{
- "_meta": { "review": "machine" },
- "{count} accounts": { "one": "{count} аккаунт", "few": "{count} аккаунта", "many": "{count} аккаунтов", "other": "{count} аккаунтов" },
- "{count} minutes": { "one": "{count} минута", "few": "{count} минуты", "many": "{count} минут", "other": "{count} минут" },
+ "_meta": {
+ "review": "machine"
+ },
+ "{count} accounts": {
+ "one": "{count} аккаунт",
+ "few": "{count} аккаунта",
+ "many": "{count} аккаунтов",
+ "other": "{count} аккаунтов"
+ },
+ "{count} minutes": {
+ "one": "{count} минута",
+ "few": "{count} минуты",
+ "many": "{count} минут",
+ "other": "{count} минут"
+ },
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "О приложении",
"Account QR code": "QR-код аккаунта",
"Add account": "Добавить аккаунт",
"Add from clipboard": "Добавить из буфера обмена",
"Auto-Lock": "Автоблокировка",
+ "Back": "",
"Bulk": "Несколько",
"Can’t reach the desktop vault": "Не удаётся получить доступ к хранилищу на компьютере",
"Cancel": "Отмена",
@@ -16,8 +30,10 @@
"Checking…": "Проверка…",
"Choose File & Import": "Выбрать файл и импортировать",
"Clipboard": "Буфер обмена",
+ "Close": "",
"Confirm new password": "Подтвердите новый пароль",
"Confirm passphrase": "Подтвердите парольную фразу",
+ "Connect your browser": "",
"Copied": "Скопировано",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "Скопировать код {issuer} для {label}. Осталось {seconds} с",
"Copy an account's code with this modifier plus its number (1–5).": "Копируйте код аккаунта этой клавишей-модификатором вместе с его номером (1–5).",
@@ -31,6 +47,7 @@
"Could not save: {error}": "Не удалось сохранить: {error}",
"Could not start: {error}": "Не удалось запустить: {error}",
"Could not unlock: {error}": "Не удалось разблокировать: {error}",
+ "Create a new vault": "",
"Create a passphrase": "Создайте парольную фразу",
"Create vault": "Создать хранилище",
"Current": "Текущее",
@@ -47,6 +64,7 @@
"Drag to reorder": "Перетащите для сортировки",
"Edit {issuer} account": "Изменить аккаунт {issuer}",
"Edit account": "Изменить аккаунт",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "Введите парольную фразу для разблокировки",
"Export Vault": "Экспортировать хранилище",
"Feedback": "Обратная связь",
@@ -57,15 +75,29 @@
"Hotkeys": "Горячие клавиши",
"How long the app can sit idle before it locks and asks for your password again.": "Как долго приложение может бездействовать, прежде чем заблокируется и снова запросит пароль.",
"Import": "Импортировать",
+ "Import a vault file": "",
"Import accounts": "Импорт аккаунтов",
"Import many, or a Google Authenticator export": "Импортируйте несколько или экспорт из Google Authenticator",
"Import Vault": "Импортировать хранилище",
- "Imported {added} of {count} accounts.": { "one": "Импортировано {added} из {count} аккаунта.", "few": "Импортировано {added} из {count} аккаунтов.", "many": "Импортировано {added} из {count} аккаунтов.", "other": "Импортировано {added} из {count} аккаунтов." },
- "Imported. The vault now holds {count} accounts.": { "one": "Импортировано. Хранилище теперь содержит {count} аккаунт.", "few": "Импортировано. Хранилище теперь содержит {count} аккаунта.", "many": "Импортировано. Хранилище теперь содержит {count} аккаунтов.", "other": "Импортировано. Хранилище теперь содержит {count} аккаунтов." },
+ "Imported {added} of {count} accounts.": {
+ "one": "Импортировано {added} из {count} аккаунта.",
+ "few": "Импортировано {added} из {count} аккаунтов.",
+ "many": "Импортировано {added} из {count} аккаунтов.",
+ "other": "Импортировано {added} из {count} аккаунтов."
+ },
+ "Imported. The vault now holds {count} accounts.": {
+ "one": "Импортировано. Хранилище теперь содержит {count} аккаунт.",
+ "few": "Импортировано. Хранилище теперь содержит {count} аккаунта.",
+ "many": "Импортировано. Хранилище теперь содержит {count} аккаунтов.",
+ "other": "Импортировано. Хранилище теперь содержит {count} аккаунтов."
+ },
"Importing…": "Импорт…",
"Issuer": "Издатель",
"Issuer (e.g. GitHub)": "Издатель (например, GitHub)",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "Она шифрует ваши аккаунты на этом устройстве и не может быть восстановлена — не забудьте её.",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "Метка",
"Label (e.g. me@x.com)": "Метка (например, me@x.com)",
"Language": "Язык",
@@ -83,16 +115,22 @@
"No QR code found in that image.": "На этом изображении QR-код не найден.",
"Not paired yet": "Ещё не сопряжено",
"Not set": "Не задано",
+ "Nothing is stored in this browser.": "",
"Off": "Выкл.",
"On": "Вкл.",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "Открывать при входе",
"Open Settings": "Открыть настройки",
+ "Pair": "",
"Pair with desktop": "Сопрячь с компьютером",
"Paired — desktop connected": "Сопряжено — компьютер подключён",
"Paired — desktop unreachable": "Сопряжено — компьютер недоступен",
"Paired. Open the popup and unlock with your desktop passphrase.": "Сопряжено. Откройте всплывающее окно и разблокируйте парольной фразой компьютера.",
+ "Pairing code": "",
"Pairing code from the desktop app": "Код сопряжения из приложения на компьютере",
+ "Pairing…": "",
"Passphrase": "Парольная фраза",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "Парольные фразы не совпадают.",
"Password changed.": "Пароль изменён.",
"Password of the file": "Пароль файла",
@@ -101,6 +139,7 @@
"Paste an otpauth:// link or Base32 secret": "Вставьте ссылку otpauth:// или секрет Base32",
"Paste from:": "Вставить из:",
"Permission to reach the desktop app was declined.": "В доступе к приложению на компьютере отказано.",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "Порт",
"Preferences": "Параметры",
"Press keys…": "Нажмите клавиши…",
@@ -117,26 +156,37 @@
"Scan to add on another device.": "Отсканируйте, чтобы добавить на другом устройстве.",
"Search": "Поиск",
"Secret (Base32)": "Секрет (Base32)",
+ "Set up 2FAU": "",
"Settings": "Настройки",
"Show 2FAU": "Показать 2FAU",
"Show QR code": "Показать QR-код",
+ "Skip for now": "",
"Source Code": "Исходный код",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "Синхронизация",
"Sync across my Chrome profile": "Синхронизировать через мой профиль Chrome",
"Sync with desktop": "Синхронизировать с компьютером",
"Tap + to add one": "Нажмите +, чтобы добавить",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "Приложение на компьютере доступно, но отклонило этот браузер. Выполните повторное сопряжение или смените режим в настройках.",
"The shortcut that opens the 2FAU popup from anywhere.": "Сочетание клавиш, которое открывает окно 2FAU из любого места.",
"This browser": "Этот браузер",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "Этот браузер хранит собственное хранилище и синхронизирует его с приложением на компьютере, когда оно запущено.",
"This browser keeps its own vault.": "Этот браузер хранит собственное хранилище.",
"Translations are community-contributed and may be incomplete.": "Переводы предоставлены сообществом и могут быть неполными.",
+ "Turn on the bridge": "",
"Unlock": "Разблокировать",
"Unlocking…": "Разблокировка…",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "Используйте не менее {count} символов.",
"Use at least 8 characters.": "Используйте не менее 8 символов.",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "Использовано {used} КБ из {quota} КБ ({percent}%).",
"Vault": "Хранилище",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "Хранилища находятся в приложении на компьютере; этот браузер — клиент.",
"Version": "Версия",
"Where the vault lives": "Где находится хранилище",
diff --git a/packages/i18n/locales/tr/ui.json b/packages/i18n/locales/tr/ui.json
index 01c8b43..eb6c0f3 100644
--- a/packages/i18n/locales/tr/ui.json
+++ b/packages/i18n/locales/tr/ui.json
@@ -1,11 +1,13 @@
{
"{count} accounts": "",
"{count} minutes": "",
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "",
"Account QR code": "",
"Add account": "",
"Add from clipboard": "",
"Auto-Lock": "",
+ "Back": "",
"Bulk": "",
"Can’t reach the desktop vault": "",
"Cancel": "",
@@ -15,8 +17,10 @@
"Checking…": "",
"Choose File & Import": "",
"Clipboard": "",
+ "Close": "",
"Confirm new password": "",
"Confirm passphrase": "",
+ "Connect your browser": "",
"Copied": "",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "",
"Copy an account's code with this modifier plus its number (1–5).": "",
@@ -30,6 +34,7 @@
"Could not save: {error}": "",
"Could not start: {error}": "",
"Could not unlock: {error}": "",
+ "Create a new vault": "",
"Create a passphrase": "",
"Create vault": "",
"Current": "",
@@ -46,6 +51,7 @@
"Drag to reorder": "",
"Edit {issuer} account": "",
"Edit account": "",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "",
"Export Vault": "",
"Feedback": "",
@@ -56,6 +62,7 @@
"Hotkeys": "",
"How long the app can sit idle before it locks and asks for your password again.": "",
"Import": "",
+ "Import a vault file": "",
"Import accounts": "",
"Import many, or a Google Authenticator export": "",
"Import Vault": "",
@@ -65,6 +72,9 @@
"Issuer": "",
"Issuer (e.g. GitHub)": "",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "",
"Label (e.g. me@x.com)": "",
"Language": "",
@@ -82,16 +92,22 @@
"No QR code found in that image.": "",
"Not paired yet": "",
"Not set": "",
+ "Nothing is stored in this browser.": "",
"Off": "",
"On": "",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "",
"Open Settings": "",
+ "Pair": "",
"Pair with desktop": "",
"Paired — desktop connected": "",
"Paired — desktop unreachable": "",
"Paired. Open the popup and unlock with your desktop passphrase.": "",
+ "Pairing code": "",
"Pairing code from the desktop app": "",
+ "Pairing…": "",
"Passphrase": "",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "",
"Password changed.": "",
"Password of the file": "",
@@ -100,6 +116,7 @@
"Paste an otpauth:// link or Base32 secret": "",
"Paste from:": "",
"Permission to reach the desktop app was declined.": "",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "",
"Preferences": "",
"Press keys…": "",
@@ -116,26 +133,37 @@
"Scan to add on another device.": "",
"Search": "",
"Secret (Base32)": "",
+ "Set up 2FAU": "",
"Settings": "",
"Show 2FAU": "",
"Show QR code": "",
+ "Skip for now": "",
"Source Code": "",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "",
"Sync across my Chrome profile": "",
"Sync with desktop": "",
"Tap + to add one": "",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "",
"The shortcut that opens the 2FAU popup from anywhere.": "",
"This browser": "",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "",
"This browser keeps its own vault.": "",
"Translations are community-contributed and may be incomplete.": "",
+ "Turn on the bridge": "",
"Unlock": "",
"Unlocking…": "",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "",
"Use at least 8 characters.": "",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "",
"Vault": "",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "",
"Version": "",
"Where the vault lives": "",
diff --git a/packages/i18n/locales/zh-CN/ui.json b/packages/i18n/locales/zh-CN/ui.json
index 7bd9819..160ab27 100644
--- a/packages/i18n/locales/zh-CN/ui.json
+++ b/packages/i18n/locales/zh-CN/ui.json
@@ -1,12 +1,20 @@
{
- "_meta": { "review": "machine" },
- "{count} accounts": { "other": "{count} 个账户" },
- "{count} minutes": { "other": "{count} 分钟" },
+ "_meta": {
+ "review": "machine"
+ },
+ "{count} accounts": {
+ "other": "{count} 个账户"
+ },
+ "{count} minutes": {
+ "other": "{count} 分钟"
+ },
+ "2FAU needs permission to talk to 127.0.0.1 to reach the desktop app.": "",
"About": "关于",
"Account QR code": "账户二维码",
"Add account": "添加账户",
"Add from clipboard": "从剪贴板添加",
"Auto-Lock": "自动锁定",
+ "Back": "",
"Bulk": "批量",
"Can’t reach the desktop vault": "无法访问桌面保险库",
"Cancel": "取消",
@@ -16,8 +24,10 @@
"Checking…": "正在检查…",
"Choose File & Import": "选择文件并导入",
"Clipboard": "剪贴板",
+ "Close": "",
"Confirm new password": "确认新密码",
"Confirm passphrase": "确认口令",
+ "Connect your browser": "",
"Copied": "已复制",
"Copy {issuer} code for {label}. {seconds} seconds remaining": "复制 {label} 的 {issuer} 验证码。剩余 {seconds} 秒",
"Copy an account's code with this modifier plus its number (1–5).": "使用此修饰键加账户编号(1–5)复制该账户的验证码。",
@@ -31,6 +41,7 @@
"Could not save: {error}": "无法保存:{error}",
"Could not start: {error}": "无法启动:{error}",
"Could not unlock: {error}": "无法解锁:{error}",
+ "Create a new vault": "",
"Create a passphrase": "创建口令",
"Create vault": "创建保险库",
"Current": "当前",
@@ -47,6 +58,7 @@
"Drag to reorder": "拖动以重新排序",
"Edit {issuer} account": "编辑 {issuer} 账户",
"Edit account": "编辑账户",
+ "Enter this code in the extension. It expires in about two minutes.": "",
"Enter your passphrase to unlock": "输入口令以解锁",
"Export Vault": "导出保险库",
"Feedback": "反馈",
@@ -57,15 +69,23 @@
"Hotkeys": "快捷键",
"How long the app can sit idle before it locks and asks for your password again.": "应用在锁定并再次要求输入密码之前可以闲置多长时间。",
"Import": "导入",
+ "Import a vault file": "",
"Import accounts": "导入账户",
"Import many, or a Google Authenticator export": "批量导入,或导入 Google Authenticator 的导出文件",
"Import Vault": "导入保险库",
- "Imported {added} of {count} accounts.": { "other": "已导入 {count} 个账户中的 {added} 个。" },
- "Imported. The vault now holds {count} accounts.": { "other": "已导入。保险库现在有 {count} 个账户。" },
+ "Imported {added} of {count} accounts.": {
+ "other": "已导入 {count} 个账户中的 {added} 个。"
+ },
+ "Imported. The vault now holds {count} accounts.": {
+ "other": "已导入。保险库现在有 {count} 个账户。"
+ },
"Importing…": "正在导入…",
"Issuer": "颁发者",
"Issuer (e.g. GitHub)": "颁发者(例如 GitHub)",
"It encrypts your accounts on this device and can’t be recovered — don’t forget it.": "它在本设备上加密你的账户且无法恢复——请勿忘记。",
+ "Its accounts become this device’s vault, and its passphrase unlocks it from now on.": "",
+ "Keep a copy here, unlocked by the same passphrase.": "",
+ "Keep the vault in 2FAU on this computer.": "",
"Label": "标签",
"Label (e.g. me@x.com)": "标签(例如 me@x.com)",
"Language": "语言",
@@ -83,16 +103,22 @@
"No QR code found in that image.": "在该图片中未找到二维码。",
"Not paired yet": "尚未配对",
"Not set": "未设置",
+ "Nothing is stored in this browser.": "",
"Off": "关",
"On": "开",
+ "Open 2FAU on this computer, turn on the browser bridge in its settings, and enter the pairing code it shows.": "",
"Open at Login": "登录时打开",
"Open Settings": "打开设置",
+ "Pair": "",
"Pair with desktop": "与桌面配对",
"Paired — desktop connected": "已配对——桌面已连接",
"Paired — desktop unreachable": "已配对——桌面无法访问",
"Paired. Open the popup and unlock with your desktop passphrase.": "已配对。打开弹出窗口并使用桌面口令解锁。",
+ "Pairing code": "",
"Pairing code from the desktop app": "来自桌面应用的配对码",
+ "Pairing…": "",
"Passphrase": "口令",
+ "Passphrase of the file": "",
"Passphrases don’t match.": "两次口令不一致。",
"Password changed.": "密码已更改。",
"Password of the file": "文件密码",
@@ -101,6 +127,7 @@
"Paste an otpauth:// link or Base32 secret": "粘贴 otpauth:// 链接或 Base32 密钥",
"Paste from:": "粘贴来源:",
"Permission to reach the desktop app was declined.": "访问桌面应用的权限被拒绝。",
+ "Pick a passphrase and start with an empty vault.": "",
"Port": "端口",
"Preferences": "偏好设置",
"Press keys…": "按下按键…",
@@ -117,26 +144,37 @@
"Scan to add on another device.": "扫描以在其他设备上添加。",
"Search": "搜索",
"Secret (Base32)": "密钥(Base32)",
+ "Set up 2FAU": "",
"Settings": "设置",
"Show 2FAU": "显示 2FAU",
"Show QR code": "显示二维码",
+ "Skip for now": "",
"Source Code": "源代码",
+ "Start a new vault, bring one you already have, or use the one in your desktop app.": "",
+ "Starting…": "",
"Sync": "同步",
"Sync across my Chrome profile": "在我的 Chrome 配置文件之间同步",
"Sync with desktop": "与桌面同步",
"Tap + to add one": "点按 + 添加一个",
+ "That file isn’t a 2FAU vault.": "",
+ "That passphrase doesn’t open this file.": "",
+ "The 2FAU extension can use this vault instead of keeping its own. You can also do this later in Settings.": "",
"The desktop app is reachable but refused this browser. Re-pair it, or switch modes in Settings.": "桌面应用可访问,但拒绝了此浏览器。请重新配对,或在设置中切换模式。",
"The shortcut that opens the 2FAU popup from anywhere.": "从任何位置打开 2FAU 弹出窗口的快捷键。",
"This browser": "此浏览器",
"This browser keeps its own vault and syncs it with the desktop app when it's running.": "此浏览器保留自己的保险库,并在桌面应用运行时与其同步。",
"This browser keeps its own vault.": "此浏览器保留自己的保险库。",
"Translations are community-contributed and may be incomplete.": "翻译由社区贡献,可能不完整。",
+ "Turn on the bridge": "",
"Unlock": "解锁",
"Unlocking…": "正在解锁…",
+ "Use a vault you exported from another device.": "",
"Use at least {count} characters.": "至少使用 {count} 个字符。",
"Use at least 8 characters.": "至少使用 8 个字符。",
+ "Use my desktop app": "",
"Using {used} KB of {quota} KB ({percent}%).": "已使用 {used} KB / {quota} KB({percent}%)。",
"Vault": "保险库",
+ "Vault file": "",
"Vaults live in the desktop app; this browser is a client.": "保险库位于桌面应用中;此浏览器是客户端。",
"Version": "版本",
"Where the vault lives": "保险库的存放位置",
diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx
index 0b1756c..f822edd 100644
--- a/packages/ui/src/app.tsx
+++ b/packages/ui/src/app.tsx
@@ -5,6 +5,7 @@ import { RootView } from "@/components/root-view";
import type { Account, ParsedOtp } from "@/core/types";
import type { VaultService } from "@/core/vault-service";
import type { SettingsBackend } from "@/core/settings";
+import type { SetupBackend } from "@/core/setup";
import { DEFAULT_QUICK_COPY, type QuickCopyConfig } from "@/lib/hotkeys";
import { ClipboardProvider } from "@/state/clipboard";
import { VaultProvider } from "@/state/vault-provider";
@@ -17,6 +18,7 @@ export function TwoFAUApp({
onScan,
onQuit,
settingsBackend,
+ setupBackend,
onOpenSettings,
matchAccount,
parseMigration,
@@ -40,6 +42,9 @@ export function TwoFAUApp({
/** In-panel settings (desktop): the gear opens the shared SettingsView driven
* by this backend. */
settingsBackend?: SettingsBackend;
+ /** First-run starting points beyond "create a vault" (import a vault file,
+ * connect to the desktop). Omitted, the wizard is just the create step. */
+ setupBackend?: SetupBackend;
/** External settings action (extension): the gear calls this instead — e.g.
* opening the options page. Takes precedence over `settingsBackend`. */
onOpenSettings?: () => void;
@@ -70,6 +75,7 @@ export function TwoFAUApp({
onScan={onScan}
onQuit={onQuit}
settingsBackend={settingsBackend}
+ setupBackend={setupBackend}
onOpenSettings={onOpenSettings}
matchAccount={matchAccount}
parseMigration={parseMigration}
diff --git a/packages/ui/src/components/root-view.tsx b/packages/ui/src/components/root-view.tsx
index 97ef201..28f1f0f 100644
--- a/packages/ui/src/components/root-view.tsx
+++ b/packages/ui/src/components/root-view.tsx
@@ -1,9 +1,9 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { AddView } from "@/components/add-view";
import { EditView } from "@/components/edit-view";
import { ImportView } from "@/components/import-view";
import { MenuBarView } from "@/components/menu-bar-view";
-import { SetupView } from "@/components/setup-view";
+import { SetupWizard } from "@/components/setup-wizard";
import { UnlockView } from "@/components/unlock-view";
import { prefillFromClipboardText } from "@/lib/prefill";
import { useClipboard } from "@/state/clipboard";
@@ -14,6 +14,7 @@ import type { ReactNode } from "react";
import type { AddPrefill } from "@/lib/prefill";
import type { Account, ParsedOtp } from "@/core/types";
import type { SettingsBackend } from "@/core/settings";
+import type { SetupBackend } from "@/core/setup";
import type { QuickCopyConfig } from "@/lib/hotkeys";
type Screen =
@@ -30,6 +31,7 @@ export function RootView({
onScan,
onQuit,
settingsBackend,
+ setupBackend,
onOpenSettings,
matchAccount,
parseMigration,
@@ -41,6 +43,7 @@ export function RootView({
onScan?: () => void;
onQuit?: () => void;
settingsBackend?: SettingsBackend;
+ setupBackend?: SetupBackend;
onOpenSettings?: () => void;
matchAccount?: (a: Account) => boolean;
parseMigration?: (uri: string) => Promise;
@@ -52,6 +55,13 @@ export function RootView({
const { locked, needsSetup } = useVault();
const { readText } = useClipboard();
const [screen, setScreen] = useState({ name: "list" });
+ // The wizard outlives the moment the vault appears: its last step (connect a
+ // browser) runs after creating or importing one, so it closes when the wizard
+ // says it is done, not when `needsSetup` flips.
+ const [setupOpen, setSetupOpen] = useState(false);
+ useEffect(() => {
+ if (locked && needsSetup) setSetupOpen(true);
+ }, [locked, needsSetup]);
// Open the Add screen seeded from the clipboard, but only when it holds a
// valid otpauth:// URI or Base32 secret. Returns false (so the caller can
@@ -83,8 +93,12 @@ export function RootView({
// overflow, exactly like the account list.
{(() => {
+ if (setupOpen) {
+ return fill(
setSetupOpen(false)} />);
+ }
+
if (locked) {
- return fill(needsSetup ? : );
+ return fill( );
}
if (screen.name === "add") {
diff --git a/packages/ui/src/components/setup-view.tsx b/packages/ui/src/components/setup-view.tsx
index b79c10d..8c226d0 100644
--- a/packages/ui/src/components/setup-view.tsx
+++ b/packages/ui/src/components/setup-view.tsx
@@ -7,9 +7,11 @@ import { useVault } from "@/state/vault-provider";
const MIN_LENGTH = 8;
-/** First-run screen: create the passphrase that encrypts the vault on this
- * device. Distinct from UnlockView, which enters an existing passphrase. */
-export function SetupView() {
+/** The wizard's create step: the passphrase that encrypts a new, empty vault on
+ * this device. Distinct from UnlockView, which enters an existing passphrase.
+ * `onDone` lets the wizard keep the panel for a following step; without it the
+ * unlocked vault simply replaces this screen. */
+export function SetupView({ onDone, onBack }: { onDone?: () => void; onBack?: () => void } = {}) {
const { t } = useT();
const { unlock } = useVault();
const [passphrase, setPassphrase] = useState("");
@@ -28,6 +30,7 @@ export function SetupView() {
setError(null);
try {
await unlock(passphrase);
+ onDone?.();
} catch (err) {
setError(
t("Could not create vault: {error}", {
@@ -74,6 +77,12 @@ export function SetupView() {
{t("Create vault")}
+
+ {onBack && (
+
+ {t("Back")}
+
+ )}
);
}
diff --git a/packages/ui/src/components/setup-wizard.test.tsx b/packages/ui/src/components/setup-wizard.test.tsx
new file mode 100644
index 0000000..72dbb7e
--- /dev/null
+++ b/packages/ui/src/components/setup-wizard.test.tsx
@@ -0,0 +1,170 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { SetupWizard } from "@/components/setup-wizard";
+import type { AdoptError, SetupBackend } from "@/core/setup";
+import type { VaultService } from "@/core/vault-service";
+import { fakeService, renderWithVault } from "@/test/test-utils";
+
+/** A host that has no vault yet — what the wizard is rendered for. */
+function freshService(): VaultService {
+ return { ...fakeService([]), isLocked: () => true, needsSetup: () => true };
+}
+
+function adoptError(code: AdoptError["code"]): AdoptError {
+ const err: AdoptError = new Error("nope");
+ err.code = code;
+ return err;
+}
+
+function fileBackend(run: (file: File, passphrase: string) => Promise): SetupBackend {
+ return { adopt: { kind: "file", run } };
+}
+
+/** Type a passphrase into the adopt step and hand it a file. */
+function chooseFile(passphrase: string) {
+ fireEvent.change(screen.getByPlaceholderText(/passphrase of the file/i), {
+ target: { value: passphrase },
+ });
+ fireEvent.change(screen.getByLabelText(/vault file/i), {
+ target: { files: [new File(["blob"], "2fau-vault.dat")] },
+ });
+}
+
+describe("SetupWizard", () => {
+ it("is just the create step when the host offers nothing else", () => {
+ const svc = freshService();
+ const unlock = vi.spyOn(svc, "unlock");
+ renderWithVault( {}} />, svc);
+
+ expect(screen.queryByText(/set up 2fau/i)).not.toBeInTheDocument();
+ const [pass, confirm] = screen.getAllByPlaceholderText(/passphrase/i);
+ fireEvent.change(pass, { target: { value: "longenough" } });
+ fireEvent.change(confirm, { target: { value: "longenough" } });
+ fireEvent.click(screen.getByRole("button", { name: /create vault/i }));
+ expect(unlock).toHaveBeenCalledWith("longenough");
+ });
+
+ it("offers every starting point the host declares", () => {
+ const backend: SetupBackend = {
+ ...fileBackend(async () => 1),
+ connect: {
+ placement: "choice",
+ title: "Use my desktop app",
+ description: "Pair with the desktop vault.",
+ screen: () => connect screen
,
+ },
+ };
+ renderWithVault( {}} />, freshService());
+
+ expect(screen.getByText(/create a new vault/i)).toBeInTheDocument();
+ expect(screen.getByText(/import a vault file/i)).toBeInTheDocument();
+ expect(screen.getByText(/use my desktop app/i)).toBeInTheDocument();
+ });
+
+ it("adopts a vault file and unlocks with the file's passphrase", async () => {
+ const svc = freshService();
+ const unlock = vi.spyOn(svc, "unlock");
+ const run = vi.fn(async () => 3);
+ const onDone = vi.fn();
+ renderWithVault( , svc);
+
+ fireEvent.click(screen.getByText(/import a vault file/i));
+ chooseFile("file-passphrase");
+
+ await waitFor(() => expect(onDone).toHaveBeenCalled());
+ expect(run).toHaveBeenCalledWith(expect.any(File), "file-passphrase");
+ expect(unlock).toHaveBeenCalledWith("file-passphrase");
+ });
+
+ it("tells a wrong passphrase from an unusable file, and creates no vault", async () => {
+ const svc = freshService();
+ const unlock = vi.spyOn(svc, "unlock");
+ const onDone = vi.fn();
+ const run = vi
+ .fn<(file: File, passphrase: string) => Promise>()
+ .mockRejectedValueOnce(adoptError("wrong-passphrase"))
+ .mockRejectedValueOnce(adoptError("bad-file"));
+ renderWithVault( , svc);
+
+ fireEvent.click(screen.getByText(/import a vault file/i));
+ chooseFile("wrong");
+ expect(await screen.findByText(/doesn.t open this file/i)).toBeInTheDocument();
+
+ chooseFile("wrong");
+ expect(await screen.findByText(/isn.t a 2fau vault/i)).toBeInTheDocument();
+
+ expect(unlock).not.toHaveBeenCalled();
+ expect(onDone).not.toHaveBeenCalled();
+ });
+
+ it("stays put when the file picker is dismissed", async () => {
+ const svc = freshService();
+ const unlock = vi.spyOn(svc, "unlock");
+ const onDone = vi.fn();
+ const backend: SetupBackend = { adopt: { kind: "native", run: async () => null } };
+ renderWithVault( , svc);
+
+ fireEvent.click(screen.getByText(/import a vault file/i));
+ fireEvent.change(screen.getByPlaceholderText(/passphrase of the file/i), {
+ target: { value: "whatever" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
+
+ await waitFor(() => expect(screen.getByPlaceholderText(/passphrase of the file/i)).toBeInTheDocument());
+ expect(unlock).not.toHaveBeenCalled();
+ expect(onDone).not.toHaveBeenCalled();
+ });
+
+ it("runs the host's connect step after the vault exists, and finishes when it is skipped", async () => {
+ const onDone = vi.fn();
+ const backend: SetupBackend = {
+ connect: {
+ placement: "after-vault",
+ title: "Connect your browser",
+ description: "Pair the extension with this app.",
+ screen: ({ onSkip }) => (
+
+ Skip
+
+ ),
+ },
+ };
+ renderWithVault( , freshService());
+
+ const [pass, confirm] = screen.getAllByPlaceholderText(/passphrase/i);
+ fireEvent.change(pass, { target: { value: "longenough" } });
+ fireEvent.change(confirm, { target: { value: "longenough" } });
+ fireEvent.click(screen.getByRole("button", { name: /create vault/i }));
+
+ const skip = await screen.findByRole("button", { name: /skip/i });
+ expect(onDone).not.toHaveBeenCalled();
+ fireEvent.click(skip);
+ expect(onDone).toHaveBeenCalled();
+ });
+
+ it("finishes on the extension's connect choice without creating a local vault", async () => {
+ const svc = freshService();
+ const unlock = vi.spyOn(svc, "unlock");
+ const onDone = vi.fn();
+ const backend: SetupBackend = {
+ ...fileBackend(async () => 1),
+ connect: {
+ placement: "choice",
+ title: "Use my desktop app",
+ description: "Pair with the desktop vault.",
+ screen: ({ onDone: done }) => (
+
+ Paired
+
+ ),
+ },
+ };
+ renderWithVault( , svc);
+
+ fireEvent.click(screen.getByText(/use my desktop app/i));
+ fireEvent.click(await screen.findByRole("button", { name: /paired/i }));
+
+ expect(onDone).toHaveBeenCalled();
+ expect(unlock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/components/setup-wizard.tsx b/packages/ui/src/components/setup-wizard.tsx
new file mode 100644
index 0000000..e2236aa
--- /dev/null
+++ b/packages/ui/src/components/setup-wizard.tsx
@@ -0,0 +1,212 @@
+import { ChevronRight, DownloadCloud, MonitorSmartphone, ShieldPlus } from "lucide-react";
+import { useState } from "react";
+import { useT } from "@twofau/i18n/react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { SetupView } from "@/components/setup-view";
+import { adoptFailure, type SetupBackend } from "@/core/setup";
+import type { ImportSpec } from "@/core/settings";
+import { useVault } from "@/state/vault-provider";
+
+type Step = "choices" | "create" | "adopt" | "connect";
+
+/** First run: what to start from. Creating a vault is always possible, so a
+ * host that declares nothing else lands straight on the passphrase form and the
+ * wizard is invisible. Everything that needs host I/O — picking a file, asking
+ * for a permission, pairing — comes from the backend, so no host API reaches
+ * this package. */
+export function SetupWizard({
+ backend,
+ onDone,
+}: {
+ backend?: SetupBackend;
+ onDone: () => void;
+}) {
+ const { t } = useT();
+ const connect = backend?.connect;
+ const hasChoices = Boolean(backend?.adopt) || connect?.placement === "choice";
+ const [step, setStep] = useState(hasChoices ? "choices" : "create");
+
+ // A vault exists from here on. The desktop has one more (optional) step; for
+ // everyone else the wizard is over and the list takes the panel.
+ const vaultReady = () => {
+ if (connect?.placement === "after-vault") setStep("connect");
+ else onDone();
+ };
+
+ const back = hasChoices ? () => setStep("choices") : undefined;
+
+ if (step === "create") return ;
+
+ if (step === "adopt" && backend?.adopt) {
+ return ;
+ }
+
+ if (step === "connect" && connect) {
+ return (
+ <>
+ {connect.screen({
+ onDone,
+ // Skipping the desktop's follow-up ends the wizard; backing out of the
+ // extension's connect choice returns to the choices.
+ onSkip: connect.placement === "after-vault" ? onDone : () => setStep("choices"),
+ })}
+ >
+ );
+ }
+
+ return (
+
+
+
+
{t("Set up 2FAU")}
+
+ {t("Start a new vault, bring one you already have, or use the one in your desktop app.")}
+
+
+
+
}
+ title={t("Create a new vault")}
+ description={t("Pick a passphrase and start with an empty vault.")}
+ onClick={() => setStep("create")}
+ />
+ {backend?.adopt && (
+
}
+ title={t("Import a vault file")}
+ description={t("Use a vault you exported from another device.")}
+ onClick={() => setStep("adopt")}
+ />
+ )}
+ {connect?.placement === "choice" && (
+
}
+ title={connect.title}
+ description={connect.description}
+ onClick={() => setStep("connect")}
+ />
+ )}
+
+ );
+}
+
+function Choice({
+ icon,
+ title,
+ description,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ onClick: () => void;
+}) {
+ return (
+
+ {icon}
+
+ {title}
+ {description}
+
+
+
+ );
+}
+
+/** Adopt an exported vault file: it becomes this device's vault, and the
+ * passphrase that opens it becomes this device's passphrase. */
+function AdoptStep({
+ spec,
+ onDone,
+ onBack,
+}: {
+ spec: ImportSpec;
+ onDone: () => void;
+ onBack?: () => void;
+}) {
+ const { t } = useT();
+ const { unlock } = useVault();
+ const [passphrase, setPassphrase] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function run(adopt: () => Promise) {
+ setBusy(true);
+ setError(null);
+ try {
+ // null means the user dismissed the host's file picker.
+ if ((await adopt()) === null) return;
+ await unlock(passphrase);
+ onDone();
+ } catch (err) {
+ setError(message(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function message(err: unknown): string {
+ switch (adoptFailure(err)) {
+ case "wrong-passphrase":
+ return t("That passphrase doesn’t open this file.");
+ case "bad-file":
+ return t("That file isn’t a 2FAU vault.");
+ default:
+ return err instanceof Error ? err.message : String(err);
+ }
+ }
+
+ return (
+
+
+
{t("Import a vault file")}
+
+ {t("Its accounts become this device’s vault, and its passphrase unlocks it from now on.")}
+
+
+
setPassphrase(e.target.value)}
+ />
+
+ {spec.kind === "native" ? (
+
void run(() => spec.run(passphrase))}
+ >
+ {busy ? t("Importing…") : t("Choose File & Import")}
+
+ ) : (
+
{
+ const file = e.target.files?.[0];
+ if (file) void run(() => spec.run(file, passphrase));
+ e.target.value = "";
+ }}
+ />
+ )}
+
+ {error &&
{error}
}
+
+ {onBack && (
+
+ {t("Back")}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/core/setup.ts b/packages/ui/src/core/setup.ts
new file mode 100644
index 0000000..4b247b0
--- /dev/null
+++ b/packages/ui/src/core/setup.ts
@@ -0,0 +1,58 @@
+import type { ReactNode } from "react";
+import type { ImportSpec } from "@/core/settings";
+
+/**
+ * The host-supplied "connect to the desktop" step of the first-run wizard.
+ *
+ * `placement` is the difference between the two hosts: the extension can live
+ * entirely in the desktop's vault, so connecting is a starting point of its own;
+ * the desktop *is* the vault, so its bridge is an optional follow-up once one
+ * exists. The screen is host-rendered (pairing and permissions are host APIs)
+ * and reports back through `onDone`/`onSkip`.
+ */
+export interface ConnectStep {
+ placement: "choice" | "after-vault";
+ /** Host-translated label for the choice (or the step's own heading). */
+ title: string;
+ /** One line under the title, explaining what connecting does here. */
+ description: string;
+ screen: (props: { onDone: () => void; onSkip: () => void }) => ReactNode;
+}
+
+/**
+ * What a host offers on first run. Creating a vault is always available — it is
+ * `VaultService.unlock` on a host reporting no vault — so only the other
+ * starting points are declared here, and the wizard renders exactly the ones
+ * present.
+ */
+export interface SetupBackend {
+ /**
+ * Adopt an existing vault file as this device's vault. Same `native`/`file`
+ * split as the Settings import, but it *replaces* rather than merges: the
+ * blob becomes the vault and the passphrase that opens the file becomes this
+ * device's passphrase. Only valid before a vault exists.
+ *
+ * `run` writes the vault and leaves it locked; the wizard unlocks it with the
+ * same passphrase, so adoption goes through the host's ordinary unlock path
+ * (keyring on desktop, session key in the extension). Rejections should carry
+ * an `AdoptError.code`.
+ */
+ adopt?: ImportSpec;
+ connect?: ConnectStep;
+}
+
+/**
+ * Why an adopt failed. Hosts tag the rejection so the wizard can tell "you
+ * typed the wrong passphrase" (retry) from "that file is not a vault" (pick
+ * another file) without parsing host-written prose.
+ */
+export type AdoptFailure = "wrong-passphrase" | "bad-file";
+
+export interface AdoptError extends Error {
+ code?: AdoptFailure;
+}
+
+export function adoptFailure(err: unknown): AdoptFailure | null {
+ const code = (err as AdoptError | null)?.code;
+ return code === "wrong-passphrase" || code === "bad-file" ? code : null;
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index f3e769e..298e15e 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -7,6 +7,7 @@ export { EditView } from "@/components/edit-view";
export { ImportView } from "@/components/import-view";
export { UnlockView } from "@/components/unlock-view";
export { SetupView } from "@/components/setup-view";
+export { SetupWizard } from "@/components/setup-wizard";
export { StatusScreen } from "@/components/status-screen";
export { SettingsView } from "@/components/settings-view";
export {
@@ -20,9 +21,18 @@ export {
type SettingsLinks,
type ImportSpec,
} from "@/core/settings";
+export {
+ adoptFailure,
+ type AdoptError,
+ type AdoptFailure,
+ type ConnectStep,
+ type SetupBackend,
+} from "@/core/setup";
export { I18nProvider, useT } from "@twofau/i18n/react";
export {
+ createTranslator,
+ type Translator,
loadMessages,
resolveLocale,
SUPPORTED_LOCALES,