From fc975aef310f692c6957019a20d88282222ef8c5 Mon Sep 17 00:00:00 2001 From: Nikolay Kostyurin Date: Sun, 20 Sep 2026 15:08:06 +0200 Subject: [PATCH 1/3] feat: openspec, initial wizard setup --- .github/workflows/changeset.yml | 13 +- apps/twofau-app/src-tauri/src/lib.rs | 28 +++ apps/twofau-app/src-tauri/src/vault.rs | 87 +++++++ apps/twofau-app/src/main.tsx | 3 + apps/twofau-app/src/setup-backend.tsx | 102 +++++++++ apps/twofau-extension/src/popup/main.tsx | 11 + .../src/popup/setup-backend.tsx | 123 ++++++++++ .../src/vault/connect-desktop.test.ts | 72 ++++++ .../src/vault/connect-desktop.ts | 37 +++ .../src/vault/extension-vault-service.test.ts | 51 +++++ .../src/vault/extension-vault-service.ts | 46 ++++ .../add-first-run-setup-wizard/.openspec.yaml | 2 + .../add-first-run-setup-wizard/design.md | 112 +++++++++ .../add-first-run-setup-wizard/proposal.md | 72 ++++++ .../specs/desktop-bridge/spec.md | 26 +++ .../specs/onboarding/spec.md | 151 +++++++++++++ .../specs/shared-ui/spec.md | 27 +++ .../add-first-run-setup-wizard/tasks.md | 33 +++ .../fix-release-pr-flow/.openspec.yaml | 2 + .../changes/fix-release-pr-flow/design.md | 79 +++++++ .../changes/fix-release-pr-flow/proposal.md | 43 ++++ .../specs/release-pipeline/spec.md | 47 ++++ openspec/changes/fix-release-pr-flow/tasks.md | 17 ++ packages/i18n/locales/de/ui.json | 52 ++++- packages/i18n/locales/en/ui.json | 28 +++ packages/i18n/locales/es/ui.json | 52 ++++- packages/i18n/locales/fr/ui.json | 28 +++ packages/i18n/locales/it/ui.json | 28 +++ packages/i18n/locales/ja/ui.json | 28 +++ packages/i18n/locales/ko/ui.json | 28 +++ packages/i18n/locales/pl/ui.json | 28 +++ packages/i18n/locales/pt-BR/ui.json | 28 +++ packages/i18n/locales/ru/ui.json | 60 ++++- packages/i18n/locales/tr/ui.json | 28 +++ packages/i18n/locales/zh-CN/ui.json | 48 +++- packages/ui/src/app.tsx | 6 + packages/ui/src/components/root-view.tsx | 20 +- packages/ui/src/components/setup-view.tsx | 15 +- .../ui/src/components/setup-wizard.test.tsx | 170 ++++++++++++++ packages/ui/src/components/setup-wizard.tsx | 212 ++++++++++++++++++ packages/ui/src/core/setup.ts | 58 +++++ packages/ui/src/index.ts | 10 + 42 files changed, 2082 insertions(+), 29 deletions(-) create mode 100644 apps/twofau-app/src/setup-backend.tsx create mode 100644 apps/twofau-extension/src/popup/setup-backend.tsx create mode 100644 apps/twofau-extension/src/vault/connect-desktop.test.ts create mode 100644 apps/twofau-extension/src/vault/connect-desktop.ts create mode 100644 openspec/changes/add-first-run-setup-wizard/.openspec.yaml create mode 100644 openspec/changes/add-first-run-setup-wizard/design.md create mode 100644 openspec/changes/add-first-run-setup-wizard/proposal.md create mode 100644 openspec/changes/add-first-run-setup-wizard/specs/desktop-bridge/spec.md create mode 100644 openspec/changes/add-first-run-setup-wizard/specs/onboarding/spec.md create mode 100644 openspec/changes/add-first-run-setup-wizard/specs/shared-ui/spec.md create mode 100644 openspec/changes/add-first-run-setup-wizard/tasks.md create mode 100644 openspec/changes/fix-release-pr-flow/.openspec.yaml create mode 100644 openspec/changes/fix-release-pr-flow/design.md create mode 100644 openspec/changes/fix-release-pr-flow/proposal.md create mode 100644 openspec/changes/fix-release-pr-flow/specs/release-pipeline/spec.md create mode 100644 openspec/changes/fix-release-pr-flow/tasks.md create mode 100644 packages/ui/src/components/setup-wizard.test.tsx create mode 100644 packages/ui/src/components/setup-wizard.tsx create mode 100644 packages/ui/src/core/setup.ts 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 ? ( + + ) : ( + <> + 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.")} +

+ + + )} + + {error &&

{error}

} + + +
+ ); +} 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]) => ( + + ))} +
+ + + + {error &&

{error}

} + + +
+ ); +} 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..43be465 --- /dev/null +++ b/openspec/changes/fix-release-pr-flow/tasks.md @@ -0,0 +1,17 @@ +## 1. Release PR branch mechanics + +- [ ] 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. +- [ ] 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'))"`). +- [ ] 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 + +- [ ] 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. +- [ ] 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. +- [ ] 2.3 Update the workflow's header comment to document the new escape hatch alongside the label. + +## 3. End-to-end verification + +- [ ] 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() { + + {onBack && ( + + )} ); } 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 }) => ( + + ), + }, + }; + 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 }) => ( + + ), + }, + }; + 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 ( + + ); +} + +/** 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" ? ( + + ) : ( + { + const file = e.target.files?.[0]; + if (file) void run(() => spec.run(file, passphrase)); + e.target.value = ""; + }} + /> + )} + + {error &&

{error}

} + + {onBack && ( + + )} +
+ ); +} 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, From 6204f3711c4ec872bbbef8653108ac416d59fca0 Mon Sep 17 00:00:00 2001 From: Nikolay Kostyurin Date: Sun, 20 Sep 2026 20:12:08 +0200 Subject: [PATCH 2/3] chore: mark verified fix-release-pr-flow tasks --- openspec/changes/fix-release-pr-flow/tasks.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/changes/fix-release-pr-flow/tasks.md b/openspec/changes/fix-release-pr-flow/tasks.md index 43be465..437ca51 100644 --- a/openspec/changes/fix-release-pr-flow/tasks.md +++ b/openspec/changes/fix-release-pr-flow/tasks.md @@ -1,17 +1,17 @@ ## 1. Release PR branch mechanics -- [ ] 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. -- [ ] 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'))"`). -- [ ] 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. +- [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 -- [ ] 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. -- [ ] 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. -- [ ] 2.3 Update the workflow's header comment to document the new escape hatch alongside the label. +- [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 -- [ ] 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`). +- [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. From a23d46c66e49dc5ae0bf167a9867971aec50d626 Mon Sep 17 00:00:00 2001 From: Nikolay Kostyurin Date: Sun, 20 Sep 2026 20:52:41 +0200 Subject: [PATCH 3/3] chore: changeset for the first-run setup wizard --- .changeset/first_run_setup_wizard.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/first_run_setup_wizard.md 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.