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
↓ peach-browser-ce399e.tar.gzatlas at this save
files changed10 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 −1manifest.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": {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",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": {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)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}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 {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 }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>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);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 () => {