ce399e

fix: natural search typing and inline totp editing (v1.4.47)

time
2026-04-23 06:21 UTC · 6 months ago
machine
koh2
parent
a7a1c1
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/aes.test.ts+3 −1src/popup/App.tsx+21 −0src/popup/styles/popup.css+18 −16src/popup/styles/vars.css+2 −6src/popup/views/Entry.tsx+62 −29src/popup/views/Vault.tsx+14 −1src/popup/views/contracts.test.tsx+60 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.46",4+  "version": "1.4.47",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.46",4+  "version": "1.4.47",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.4.46",3+  "version": "1.4.47",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/aes.test.ts+3 −1
@@ -32,7 +32,9 @@3232   it("decrypt with tampered ciphertext throws", async () => {3333     const key = await deriveKey("correct-pass-12345", generateSalt());3434     const encrypted = await encrypt(key, "secret");35-    const tamperedCiphertext = `${encrypted.ciphertext.slice(0, -2)}ff`;35+    const lastByte = Number.parseInt(encrypted.ciphertext.slice(-2), 16);36+    const tamperedByte = (lastByte ^ 0xff).toString(16).padStart(2, "0");37+    const tamperedCiphertext = `${encrypted.ciphertext.slice(0, -2)}${tamperedByte}`;3638 3739     await expect(3840       decrypt(key, encrypted.nonce, tamperedCiphertext, encrypted.tag)

view file

src/popup/App.tsx+21 −0
@@ -187,6 +187,8 @@187187   const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);188188   const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");189189   const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");190+  const [listSearchSeed, setListSearchSeed] = createSignal("");191+  const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);190192 191193   let contentPhaseTimer: number | undefined;192194 @@ -309,6 +311,11 @@309311       const inInput = (e.target as HTMLElement)?.tagName === "INPUT"310312         || (e.target as HTMLElement)?.tagName === "TEXTAREA"311313         || (e.target as HTMLElement)?.isContentEditable;314+      const isPrintableSearchKey =315+        e.key.length === 1 &&316+        !e.ctrlKey &&317+        !e.metaKey &&318+        !e.altKey;312319 313320       // ── Escape ────────────────────────────────────────────────314321       if (e.key === "Escape") {@@ -323,6 +330,12 @@323330         return;324331       }325332 333+      if (v === "entry" && !inInput && isPrintableSearchKey) {334+        e.preventDefault();335+        primeListSearch(e.key);336+        return;337+      }338+326339       // ── Lock ──────────────────────────────────────────────────327340       if (e.ctrlKey && e.key === "l") {328341         e.preventDefault();@@ -419,6 +432,12 @@419432     await sendMessage({ type: "UPDATE_SETTINGS", payload: { trashRetentionDays: days } });420433   }421434 435+  function primeListSearch(seed: string) {436+    setListSearchSeed(seed);437+    setListSearchSeedNonce((value) => value + 1);438+    openList();439+  }440+422441   // Returns null when it detected a dead service worker and triggered handleLock().423442   // Callers that need to abort (e.g. openVault) check for null rather than relying424443   // on view() state, which may already be "lock" during normal startup flows.@@ -778,6 +797,8 @@778797                 selectedEntryId={selectedEntry()?.id}779798                 currentTabDomain={currentTabDomain()}780799                 sortOrder={sortOrder()}800+                searchSeed={listSearchSeed()}801+                searchSeedNonce={listSearchSeedNonce()}781802                 onSelectEntry={handleSelectEntry}782803                 onSortOrderChange={setSortOrder}783804                 onAdd={handleAdd}

view file

src/popup/styles/popup.css+18 −16
@@ -82,11 +82,11 @@8282     radial-gradient(120% 80% at 0% 0%, rgba(217,104,48,0.08) 0%, transparent 65%),8383     radial-gradient(85% 100% at 100% 100%, rgba(217,104,48,0.06) 0%, transparent 70%),8484     var(--peach-bg);85-  border: 1px solid var(--shell-frame);85+  border: 1px solid var(--peach-t3);8686   box-shadow:8787     0 2px 8px rgba(0,0,0,0.55),8888     0 14px 48px rgba(0,0,0,0.7),89-    0 0 0 1px var(--shell-outline);89+    0 0 0 1px rgba(240,232,216,0.04);9090   overflow: hidden;9191 }9292 .peach-shell {@@ -99,12 +99,12 @@9999 .peach-shell::after {100100   content: "";101101   position: absolute;102-  inset: 0;102+  inset: 2px;103103   border: 1px solid rgba(217, 104, 48, 0);104104   box-shadow: 0 0 0 rgba(217, 104, 48, 0);105-  border-radius: inherit;105+  border-radius: 1px;106106   opacity: 0;107-  transform: scale(0.997);107+  transform: scale(0.998);108108   pointer-events: none;109109   z-index: 20;110110 }@@ -734,29 +734,31 @@734734     opacity: 0;735735     border-color: rgba(217, 104, 48, 0);736736     box-shadow: 0 0 0 rgba(217, 104, 48, 0);737-    transform: scale(0.997);737+    transform: scale(0.998);738738   }739739   36% {740-    opacity: 0.92;741-    border-color: rgba(217, 104, 48, 0.5);740+    opacity: 0.88;741+    border-color: rgba(217, 104, 48, 0.42);742742     box-shadow:743-      0 0 0 1px rgba(217, 104, 48, 0.14),744-      0 0 14px rgba(217, 104, 48, 0.16);743+      inset 0 0 0 1px rgba(217, 104, 48, 0.16),744+      inset 0 0 16px rgba(217, 104, 48, 0.14),745+      0 0 10px rgba(217, 104, 48, 0.08);745746     transform: scale(1);746747   }747748   64% {748-    opacity: 0.5;749-    border-color: rgba(217, 104, 48, 0.28);749+    opacity: 0.46;750+    border-color: rgba(217, 104, 48, 0.24);750751     box-shadow:751-      0 0 0 1px rgba(217, 104, 48, 0.1),752-      0 0 18px rgba(217, 104, 48, 0.1);753-    transform: scale(1.0008);752+      inset 0 0 0 1px rgba(217, 104, 48, 0.1),753+      inset 0 0 20px rgba(217, 104, 48, 0.1),754+      0 0 12px rgba(217, 104, 48, 0.06);755+    transform: scale(1.0005);754756   }755757   100% {756758     opacity: 0;757759     border-color: rgba(217, 104, 48, 0);758760     box-shadow: 0 0 0 rgba(217, 104, 48, 0);759-    transform: scale(1.0016);761+    transform: scale(1.0012);760762   }761763 }762764 .peach-search-bar {

view file

src/popup/styles/vars.css+2 −6
@@ -90,12 +90,10 @@9090   --shell-top-fade: rgba(12, 10, 6, 0.95);9191   --shell-accent-a: rgba(217, 104, 48, 0.08);9292   --shell-accent-b: rgba(217, 104, 48, 0.06);93-  --shell-frame: rgba(0, 0, 0, 0.86);94-  --shell-outline: rgba(0, 0, 0, 0.42);9593   --breach: rgba(220, 80, 60, 0.7);9694   --health: rgba(100, 210, 100, 0.75);9795   --totp-tag: rgba(120, 175, 255, 0.75);98-  --copy-shadow: 0 2px 8px rgba(0,0,0,0.55), 0 14px 48px rgba(0,0,0,0.7), 0 0 0 1px var(--shell-outline);96+  --copy-shadow: 0 2px 8px rgba(0,0,0,0.55), 0 14px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(240,232,216,0.04);9997   --font-display: "Cormorant Garamond", "Times New Roman", serif;10098   --font-mono: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;10199   --font-ui: "Inter Variable", "Inter", system-ui, sans-serif;@@ -152,7 +150,5 @@152150   --shell-top-fade: rgba(198, 180, 160, 0.96);153151   --shell-accent-a: rgba(217, 104, 48, 0.12);154152   --shell-accent-b: rgba(123, 144, 96, 0.10);155-  --shell-frame: rgba(0, 0, 0, 0.78);156-  --shell-outline: rgba(0, 0, 0, 0.26);157-  --copy-shadow: 0 2px 8px rgba(80, 56, 32, 0.22), 0 14px 48px rgba(80, 56, 32, 0.20), 0 0 0 1px var(--shell-outline);153+  --copy-shadow: 0 2px 8px rgba(80, 56, 32, 0.22), 0 14px 48px rgba(80, 56, 32, 0.20), 0 0 0 1px rgba(28,25,21,0.06);158154 }

view file

src/popup/views/Entry.tsx+62 −29
@@ -2,7 +2,7 @@22 import type { VaultEntry } from "../../shared/types";33 import { getDisplayDomain } from "../../shared/domain";44 import { faviconUrl } from "../../shared/favicon";5-import { generateTOTP } from "../../crypto/totp";5+import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";66 import { sendMessage, triggerPipPulse, triggerRing, triggerTotpRing } from "../App";77 import { GeneratorDrawer } from "../components/GeneratorDrawer";88 @@ -105,7 +105,11 @@105105     const entry = props.entry;106106     if (!field || !entry) return;107107     // Don't trim passwords — spaces may be intentional108-    const value = field === "password" ? draft() : draft().trim();108+    let value = field === "password" ? draft() : draft().trim();109+    if (field === "totp" && value.startsWith("otpauth://")) {110+      const parsed = parseOtpauthURI(value);111+      value = parsed?.secret ?? value;112+    }109113     if (field === "password" && entry.type !== "note" && !value) {110114       return;111115     }@@ -662,34 +666,63 @@662666               <Show when={totpState()}>663667                 {(state) => (664668                   <section class="peach-totp-band">665-                    <div class="peach-totp-main">666-                      <span class="peach-label">2-factor</span>667-                      <div class="peach-totp-code-row">668-                        <p class="peach-totp-code">669-                          <span class="peach-totp-code-text">670-                            {state().code.slice(0, 3)} {state().code.slice(3)}671-                          </span>672-                        </p>673-                        <button674-                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}675-                          onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}676-                          aria-label="Copy TOTP code"677-                        >678-                          <CopyGlyph /><span class="peach-copy-sheen" />679-                        </button>680-                      </div>681-                    </div>682-                    <div class="peach-totp-meta">683-                      <span class="peach-label">expires</span>684-                      <div class="peach-totp-countdown">{state().remaining}s</div>685-                      <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">686-                        <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />687-                        <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"688-                          stroke-dasharray="106.8"689-                          stroke-dashoffset={(1 - state().remaining / 30) * 106.8}669+                    <Show670+                      when={editingField() === "totp"}671+                      fallback={672+                        <>673+                          <div674+                            class="peach-totp-main peach-inline-clickable"675+                            title="Click to edit 2FA secret"676+                            onClick={() => startEdit("totp", entry().totp ?? "")}677+                          >678+                            <span class="peach-label">2-factor</span>679+                            <div class="peach-totp-code-row">680+                              <p class="peach-totp-code">681+                                <span class="peach-totp-code-text">682+                                  {state().code.slice(0, 3)} {state().code.slice(3)}683+                                </span>684+                              </p>685+                              <button686+                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}687+                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}688+                                aria-label="Copy TOTP code"689+                              >690+                                <CopyGlyph /><span class="peach-copy-sheen" />691+                              </button>692+                            </div>693+                          </div>694+                          <div class="peach-totp-meta">695+                            <span class="peach-label">expires</span>696+                            <div class="peach-totp-countdown">{state().remaining}s</div>697+                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">698+                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />699+                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"700+                                stroke-dasharray="106.8"701+                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}702+                              />703+                            </svg>704+                          </div>705+                        </>706+                      }707+                    >708+                      <div709+                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"710+                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}711+                      >712+                        <span class="peach-label">2-factor</span>713+                        <input714+                          class="peach-inline-input"715+                          value={draft()}716+                          onInput={(e) => setDraft(e.currentTarget.value)}717+                          onKeyDown={onEditKeyDown}718+                          ref={autoFocus}719+                          disabled={saving()}720+                          placeholder="otpauth://... or base32 secret"721+                          autocomplete="off"690722                         />691-                      </svg>692-                    </div>723+                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />724+                      </div>725+                    </Show>693726                   </section>694727                 )}695728               </Show>

view file

src/popup/views/Vault.tsx+14 −1
@@ -1,4 +1,4 @@1-import { createMemo, createSignal, For, Show } from "solid-js";1+import { createEffect, createMemo, createSignal, For, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";33 import { getDisplayDomain } from "../../shared/domain";44 import { faviconUrl } from "../../shared/favicon";@@ -42,6 +42,8 @@4242   selectedEntryId?: string;4343   currentTabDomain?: string;4444   sortOrder: SortOrder;45+  searchSeed?: string;46+  searchSeedNonce?: number;4547   onSelectEntry: (entry: VaultEntry) => void;4648   onSortOrderChange: (order: SortOrder) => void;4749   onAdd: () => void;@@ -64,6 +66,17 @@6466     searchRef = el;6567     setTimeout(() => el.focus(), 50);6668   };69+70+  createEffect(() => {71+    void props.searchSeedNonce;72+    const seed = props.searchSeed;73+    if (!seed) return;74+    setSearch(seed);75+    requestAnimationFrame(() => {76+      searchRef?.focus();77+      searchRef?.setSelectionRange(seed.length, seed.length);78+    });79+  });6780 6881   function enterSelectMode() {6982     setSelecting(true);

view file

src/popup/views/contracts.test.tsx+60 −1
@@ -10,6 +10,7 @@1010 import { SettingsView } from "./Settings";1111 import { EntryView } from "./Entry";1212 import { ImportView } from "./Import";13+import { VaultView } from "./Vault";1314 import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1415 1516 const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();@@ -41,6 +42,10 @@4142 4243 vi.mock("../../crypto/totp", () => ({4344   generateTOTP: () => ({ code: "123 456", remaining: 24 }),45+  parseOtpauthURI: (value: string) =>46+    value.startsWith("otpauth://")47+      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }48+      : null,4449 }));4550 4651 vi.mock("../components/GeneratorDrawer", () => ({@@ -93,7 +98,7 @@9398 9499     (globalThis as { chrome?: typeof chrome }).chrome = {95100       runtime: {96-        getManifest: () => ({ version: "1.4.46" }),101+        getManifest: () => ({ version: "1.4.47" }),97102         onMessage: { addListener: vi.fn() },98103       },99104     } as unknown as typeof chrome;@@ -490,6 +495,60 @@490495     expect(sendMessageMock).not.toHaveBeenCalledWith(491496       expect.objectContaining({ type: "UPDATE_ENTRY" })492497     );498+  });499+500+  it("Entry lets TOTP secrets be edited inline", async () => {501+    sendMessageMock.mockResolvedValue({ ok: true });502+    const onFieldSaved = vi.fn(async () => undefined);503+    const container = mount(504+      <EntryView505+        entry={sampleEntry()}506+        onDelete={vi.fn()}507+        onFieldSaved={onFieldSaved}508+      />509+    );510+511+    const totpMain = container.querySelector(".peach-totp-main.peach-inline-clickable") as HTMLDivElement;512+    totpMain.click();513+    await flushView();514+515+    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;516+    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");517+    await flushView();518+519+    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;520+    saveButton.click();521+    await flushView();522+523+    expect(sendMessageMock).toHaveBeenCalledWith({524+      type: "UPDATE_ENTRY",525+      payload: {526+        id: "entry-1",527+        patch: { totp: "NB2W45DFOIZA====" },528+      },529+    });530+    expect(onFieldSaved).toHaveBeenCalled();531+  });532+533+  it("Vault seeds and focuses search when a search seed is provided", async () => {534+    const container = mount(535+      <VaultView536+        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}537+        sortOrder="url-first"538+        searchSeed="g"539+        searchSeedNonce={1}540+        onSelectEntry={vi.fn()}541+        onSortOrderChange={vi.fn()}542+        onAdd={vi.fn()}543+        onClose={vi.fn()}544+      />545+    );546+547+    await flushView();548+549+    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;550+    expect(search.value).toBe("g");551+    expect(document.activeElement).toBe(search);493552   });494553 495554   it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {

view file