032574cf5b9a
additionalUrls + remember-site toast + 30% glow reduction (v1.9.0)
- time
- 2026-04-24 23:21 UTC · 6 months ago
- machine
- koh2
- parent
- 17d54c31d9c5
↓ peach-browser-032574cf.tar.gzatlas at this save
files changed14 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+11 −0src/background/vault.test.ts+53 −0src/background/vault.ts+48 −5src/content/index.ts+12 −7src/content/inject.ts+9 −9src/popup/App.tsx+10 −4src/popup/styles/popup.css+54 −12src/popup/styles/vars.css+3 −3src/popup/views/Entry.tsx+67 −16src/popup/views/Vault.tsx+17 −4src/shared/types.ts+8 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "1.8.9",4+ "version": "1.9.0",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.8.9",4+ "version": "1.9.0",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.8.9",3+ "version": "1.9.0",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/messages.ts+11 −0
@@ -145,6 +145,17 @@145145 await vault.updateEntry(id, patch);146146 return { ok: true };147147 }148+ case "ADD_ADDITIONAL_URL": {149+ const { id, url } = msg.payload as { id: string; url: string };150+ const trimmed = typeof url === "string" ? url.trim() : "";151+ if (!trimmed) return { ok: false, error: "url required" };152+ const existing = vault.getEntryById(id);153+ if (!existing) return { ok: false, error: "entry not found" };154+ if (existing.type !== "login") return { ok: false, error: "login entries only" };155+ const merged = [...(existing.additionalUrls ?? []), trimmed];156+ await vault.updateEntry(id, { additionalUrls: merged });157+ return { ok: true };158+ }148159 case "DELETE_ENTRY": {149160 const { id } = msg.payload as { id: string };150161 await vault.deleteEntry(id);src/background/vault.test.ts+53 −0
@@ -828,4 +828,57 @@828828 829829 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);830830 });831+832+ it("queryDomain matches an entry against its additionalUrls", async () => {833+ const vault = await import("./vault");834+835+ await vault.createVault("multi-domain-pass-12345");836+ const entry = await vault.addEntry({837+ type: "login",838+ title: "Foo",839+ username: "june",840+ password: "secret",841+ url: "https://foo.com",842+ additionalUrls: ["https://bar.io", "https://baz.org"],843+ });844+845+ // Vault index keys by eTLD+1 (tldts.getDomain), so additionalUrls only846+ // add new buckets when the eTLD+1 is genuinely different from the primary.847+ expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);848+ expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);849+ expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);850+ expect(vault.queryDomain("unrelated.com")).toEqual([]);851+ });852+853+ it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {854+ const vault = await import("./vault");855+856+ await vault.createVault("dedupe-additional-pass-12345");857+ const entry = await vault.addEntry({858+ type: "login",859+ title: "Foo",860+ username: "june",861+ password: "secret",862+ url: "https://foo.com",863+ });864+865+ await vault.updateEntry(entry.id, {866+ additionalUrls: [867+ " https://login.foo.com ",868+ "https://login.foo.com",869+ "",870+ " ",871+ "https://accounts.foo.com",872+ ],873+ });874+875+ const stored = vault.getEntryById(entry.id);876+ expect(stored?.additionalUrls).toEqual([877+ "https://login.foo.com",878+ "https://accounts.foo.com",879+ ]);880+881+ await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });882+ expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();883+ });831884 });src/background/vault.ts+48 −5
@@ -330,12 +330,24 @@330330 if (entry.deletedAt) continue;331331 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;332332 333- const domain = extractDomain(entry.url ?? "");334- if (!domain) continue;333+ // An entry can match multiple sites via `additionalUrls`. Index each334+ // hostname so `queryDomain` returns the entry regardless of which alias335+ // the user is currently visiting. Dedupe per-entry so a single entry336+ // never appears twice in one bucket.337+ const domains = new Set<string>();338+ const primary = extractDomain(entry.url ?? "");339+ if (primary) domains.add(primary);340+ for (const extra of entry.additionalUrls ?? []) {341+ const d = extractDomain(extra);342+ if (d) domains.add(d);343+ }344+ if (domains.size === 0) continue;335345 336- const bucket = domainIndex.get(domain) ?? [];337- bucket.push(entry);338- domainIndex.set(domain, bucket);346+ for (const domain of domains) {347+ const bucket = domainIndex.get(domain) ?? [];348+ bucket.push(entry);349+ domainIndex.set(domain, bucket);350+ }339351 }340352 }341353 @@ -877,6 +889,21 @@877889 buildDomainIndexSync(entries);878890 }879891 892+function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {893+ if (!Array.isArray(values)) return undefined;894+ const seen = new Set<string>();895+ const cleaned: string[] = [];896+ for (const value of values) {897+ const trimmed = typeof value === "string" ? value.trim() : "";898+ if (!trimmed) continue;899+ const key = trimmed.toLowerCase();900+ if (seen.has(key)) continue;901+ seen.add(key);902+ cleaned.push(trimmed);903+ }904+ return cleaned.length > 0 ? cleaned : undefined;905+}906+880907 function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {881908 const title = entry.title.trim();882909 const type = entry.type ?? "login";@@ -912,6 +939,7 @@912939 username: type === "login" ? entry.username?.trim() ?? "" : undefined,913940 password: type === "login" ? entry.password : undefined,914941 url: type === "login" ? entry.url?.trim() ?? "" : undefined,942+ additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,915943 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,916944 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,917945 breach: type === "login" ? entry.breach : undefined,@@ -957,6 +985,21 @@957985 958986 if ("url" in patch) {959987 nextPatch.url = patch.url?.trim() ?? "";988+ }989+990+ if ("additionalUrls" in patch) {991+ const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];992+ const seen = new Set<string>();993+ const cleaned: string[] = [];994+ for (const value of incoming) {995+ const trimmed = typeof value === "string" ? value.trim() : "";996+ if (!trimmed) continue;997+ const key = trimmed.toLowerCase();998+ if (seen.has(key)) continue;999+ seen.add(key);1000+ cleaned.push(trimmed);1001+ }1002+ nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;9601003 }9611004 9621005 if ("username" in patch) {src/content/index.ts+12 −7
@@ -11,7 +11,7 @@1111 observeAnchorPosition,1212 SelectorEntry,1313 showAutofillSelector,14- showDomainConfirmToast,14+ showRememberSiteToast,1515 } from "./inject";1616 import { fillFields } from "./fill";1717 import { setupSaveDetection } from "./save";@@ -346,13 +346,18 @@346346 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;347347 if (entry) {348348 await handleAutofill(anchorField, usernameEl, passwordEl, entry);349- // Offer to set page domain as default for this entry when it differs.349+ // Offer to remember the current site for this entry only if none of350+ // its known domains (primary + additional) match — otherwise the351+ // user already mapped it and the prompt would be noise.350352 const pageDomain = location.hostname.replace(/^www\./, "");351- const entryDomain = (() => {352- try { return new URL(entry.url ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }353- })();354- if (entryDomain && pageDomain && entryDomain !== pageDomain) {355- showDomainConfirmToast(pageDomain, entry.id);353+ const entryDomains = new Set<string>();354+ for (const u of [entry.url, ...(entry.additionalUrls ?? [])]) {355+ try {356+ if (u) entryDomains.add(new URL(u).hostname.replace(/^www\./, ""));357+ } catch { /* ignore unparseable */ }358+ }359+ if (pageDomain && entryDomains.size > 0 && !entryDomains.has(pageDomain)) {360+ showRememberSiteToast(pageDomain, entry.id);356361 }357362 }358363 },src/content/inject.ts+9 −9
@@ -97,11 +97,11 @@9797 }9898 9999 /**100- * Shows a bottom-of-page toast after filling credentials when the entry's101- * saved domain doesn't match the current site — offering to set the current102- * origin as the entry's default URL. Closes automatically after 9 seconds.100+ * Shows a bottom-of-page toast after filling credentials when the entry has101+ * never matched the current site — offering to add the current origin to the102+ * entry's `additionalUrls` so future visits auto-suggest it. Closes after 9s.103103 */104-export function showDomainConfirmToast(104+export function showRememberSiteToast(105105 pageDomain: string,106106 entryId: string,107107 ): void {@@ -135,18 +135,18 @@135135 136136 const bar = document.createElement("div"); bar.className = "bar";137137 const txt = document.createElement("span"); txt.className = "txt";138- txt.textContent = `Set ${pageDomain} as default for this entry?`;138+ txt.textContent = `Remember ${pageDomain} for this entry?`;139139 const yesBtn = document.createElement("button"); yesBtn.className = "yes";140- yesBtn.textContent = "Set default";140+ yesBtn.textContent = "Remember";141141 yesBtn.addEventListener("click", async () => {142142 await chrome.runtime.sendMessage({143- type: "UPDATE_ENTRY",144- payload: { id: entryId, patch: { url: location.origin } },143+ type: "ADD_ADDITIONAL_URL",144+ payload: { id: entryId, url: location.origin },145145 });146146 host.remove();147147 });148148 const noBtn = document.createElement("button"); noBtn.className = "no";149- noBtn.textContent = "Skip";149+ noBtn.textContent = "Not now";150150 noBtn.addEventListener("click", () => host.remove());151151 bar.append(txt, yesBtn, noBtn);152152 shadow.appendChild(bar);src/popup/App.tsx+10 −4
@@ -182,9 +182,9 @@182182 document.documentElement.style.setProperty("--peach-accent", hex);183183 // Ambient surface variables — derived from the accent so surfaces shift184184 // subtly toward the accent hue rather than staying fixed warm-brown.185- document.documentElement.style.setProperty("--peach-glow", hex + "77");186- document.documentElement.style.setProperty("--peach-surface-tint", hex + "1A");187- document.documentElement.style.setProperty("--peach-border-accent", hex + "66");185+ document.documentElement.style.setProperty("--peach-glow", hex + "53");186+ document.documentElement.style.setProperty("--peach-surface-tint", hex + "12");187+ document.documentElement.style.setProperty("--peach-border-accent", hex + "47");188188 }189189 190190 createEffect(() => {@@ -505,7 +505,13 @@505505 const tabDomain = currentTabDomain();506506 if (tabDomain) {507507 nextSelectedEntry = loadedEntries.find((e) => {508- if (e.type === "login" && e.url) return (extractDomain(e.url) ?? "") === tabDomain;508+ if (e.type === "login") {509+ if (e.url && (extractDomain(e.url) ?? "") === tabDomain) return true;510+ for (const extra of e.additionalUrls ?? []) {511+ if ((extractDomain(extra) ?? "") === tabDomain) return true;512+ }513+ return false;514+ }509515 if (e.type === "passkey" && e.rpId) return e.rpId === tabDomain;510516 return false;511517 }) ?? null;src/popup/styles/popup.css+54 −12
@@ -618,6 +618,48 @@618618 margin-top: 8px;619619 }620620 621+/* Additional URLs editor revealed by clicking the URL field */622+.peach-additional-urls {623+ display: flex;624+ flex-direction: column;625+ gap: 6px;626+ margin-top: 8px;627+}628+.peach-additional-urls-label {629+ font-size: 9px;630+ letter-spacing: 0.1em;631+ text-transform: uppercase;632+ color: var(--peach-t2);633+ margin-bottom: 2px;634+}635+.peach-additional-url-row {636+ display: flex;637+ align-items: center;638+ gap: 6px;639+}640+.peach-additional-url-row .peach-inline-input { flex: 1; min-width: 0; }641+.peach-additional-url-remove {642+ background: transparent;643+ border: 1px solid var(--peach-t3);644+ color: var(--peach-t2);645+ width: 22px;646+ height: 22px;647+ border-radius: 4px;648+ cursor: pointer;649+ font-size: 14px;650+ line-height: 1;651+ display: inline-flex;652+ align-items: center;653+ justify-content: center;654+ flex-shrink: 0;655+ transition: color 120ms ease, border-color 120ms ease;656+}657+.peach-additional-url-remove:hover:not(:disabled) {658+ color: var(--peach-accent);659+ border-color: var(--peach-border-accent);660+}661+.peach-additional-urls-add { align-self: flex-start; }662+621663 /* Delete row */622664 .peach-entry-delete-row {623665 display: flex;@@ -784,19 +826,19 @@784826 background: var(--peach-accent);785827 color: rgba(0,0,0,0.78);786828 box-shadow:787- 0 1px 0 rgba(255,255,255,0.22) inset,788- 0 0 0 1px color-mix(in srgb, var(--peach-accent) 60%, transparent),829+ 0 1px 0 rgba(255,255,255,0.15) inset,830+ 0 0 0 1px color-mix(in srgb, var(--peach-accent) 42%, transparent),789831 0 4px 22px var(--peach-glow),790- 0 0 32px color-mix(in srgb, var(--peach-accent) 28%, transparent);832+ 0 0 32px color-mix(in srgb, var(--peach-accent) 20%, transparent);791833 }792834 .peach-btn--accent:hover:not(:disabled) {793835 background: var(--peach-accent);794- filter: brightness(1.15) saturate(1.1);836+ filter: brightness(1.12) saturate(1.07);795837 box-shadow:796- 0 1px 0 rgba(255,255,255,0.3) inset,797- 0 0 0 1px color-mix(in srgb, var(--peach-accent) 70%, transparent),798- 0 6px 28px color-mix(in srgb, var(--peach-accent) 60%, transparent),799- 0 0 40px color-mix(in srgb, var(--peach-accent) 38%, transparent);838+ 0 1px 0 rgba(255,255,255,0.21) inset,839+ 0 0 0 1px color-mix(in srgb, var(--peach-accent) 50%, transparent),840+ 0 6px 28px color-mix(in srgb, var(--peach-accent) 42%, transparent),841+ 0 0 40px color-mix(in srgb, var(--peach-accent) 27%, transparent);800842 }801843 .peach-btn--ghost { border-color: var(--peach-t3); color: var(--peach-t1); }802844 .peach-btn--subtle { color: var(--peach-t2); border-color: var(--peach-t3); }@@ -3202,10 +3244,10 @@32023244 .peach-lock-submit {32033245 width: 100%;32043246 box-shadow:3205- 0 1px 0 rgba(255,255,255,0.28) inset,3206- 0 0 0 1px color-mix(in srgb, var(--peach-accent) 55%, transparent),3207- 0 16px 44px color-mix(in srgb, var(--peach-accent) 55%, transparent),3208- 0 0 60px color-mix(in srgb, var(--peach-accent) 28%, transparent);3247+ 0 1px 0 rgba(255,255,255,0.20) inset,3248+ 0 0 0 1px color-mix(in srgb, var(--peach-accent) 38%, transparent),3249+ 0 16px 44px color-mix(in srgb, var(--peach-accent) 38%, transparent),3250+ 0 0 60px color-mix(in srgb, var(--peach-accent) 20%, transparent);32093251 }32103252 32113253 .peach-lock-secondary {src/popup/styles/vars.css+3 −3
@@ -113,9 +113,9 @@113113 --peach-t3: var(--t3);114114 --peach-accent: var(--ac);115115 --peach-accent-dim: color-mix(in srgb, var(--peach-accent) 25%, transparent);116- --peach-glow: #F49C7077; /* accent bloom behind buttons/illustrations — set by applyAccent() */117- --peach-surface-tint: #F49C701A; /* accent tint on card surfaces — set by applyAccent() */118- --peach-border-accent: #F49C7066; /* accent-tinted border for active states — set by applyAccent() */116+ --peach-glow: #F49C7053; /* accent bloom behind buttons/illustrations — set by applyAccent() */117+ --peach-surface-tint: #F49C7012; /* accent tint on card surfaces — set by applyAccent() */118+ --peach-border-accent: #F49C7047; /* accent-tinted border for active states — set by applyAccent() */119119 --peach-breach: var(--breach);120120 --peach-health-ok: var(--health);121121 --peach-totp-tag: var(--totp-tag);src/popup/views/Entry.tsx+67 −16
@@ -25,6 +25,9 @@2525 const [showPassword, setShowPassword] = createSignal(false);2626 const [showGenerator, setShowGenerator] = createSignal(false);2727 const [copied, setCopied] = createSignal<string | null>(null);28+ // Local draft for the additional URLs list, edited only while the URL field29+ // is in inline-edit mode. Saved alongside `url` when the user confirms.30+ const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);2831 const [showCardNumber, setShowCardNumber] = createSignal(false);2932 const [showCardCvc, setShowCardCvc] = createSignal(false);3033 const [totpTick, setTotpTick] = createSignal(0);@@ -135,6 +138,9 @@135138 setDraft(value);136139 setShowGenerator(false);137140 setConfirmDelete(false);141+ if (field === "url") {142+ setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);143+ }138144 }139145 140146 async function saveField() {@@ -152,9 +158,14 @@152158 }153159 setSaving(true);154160 try {161+ const patch: Partial<VaultEntry> = { [field]: value };162+ if (field === "url") {163+ // Sanitization layer in vault.ts trims, dedupes, and drops empties.164+ patch.additionalUrls = additionalUrlsDraft();165+ }155166 await sendMessage({156167 type: "UPDATE_ENTRY",157- payload: { id: entry.id, patch: { [field]: value } },168+ payload: { id: entry.id, patch },158169 });159170 triggerRing();160171 setEditingField(null);@@ -441,21 +452,61 @@441452 </div>442453 }443454 >444- <div445- class="peach-inline-field-wrap"446- onFocusOut={(event) => handleEditorFocusOut("url", event)}447- >448- <input449- class="peach-inline-input"450- type="url"451- placeholder="https://"452- value={draft()}453- onInput={(e) => setDraft(e.currentTarget.value)}454- onKeyDown={onEditKeyDown}455- ref={autoFocus}456- disabled={saving()}457- />458- <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />455+ <div>456+ <div457+ class="peach-inline-field-wrap"458+ onFocusOut={(event) => handleEditorFocusOut("url", event)}459+ >460+ <input461+ class="peach-inline-input"462+ type="url"463+ placeholder="https://"464+ value={draft()}465+ onInput={(e) => setDraft(e.currentTarget.value)}466+ onKeyDown={onEditKeyDown}467+ ref={autoFocus}468+ disabled={saving()}469+ />470+ <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />471+ </div>472+ <div class="peach-additional-urls">473+ <Show when={additionalUrlsDraft().length > 0}>474+ <span class="peach-label peach-additional-urls-label">also matches</span>475+ </Show>476+ <For each={additionalUrlsDraft()}>477+ {(value, i) => (478+ <div class="peach-additional-url-row">479+ <input480+ class="peach-inline-input"481+ type="url"482+ placeholder="https://"483+ value={value}484+ onInput={(e) => setAdditionalUrlsDraft((arr) => {485+ const next = [...arr];486+ next[i()] = e.currentTarget.value;487+ return next;488+ })}489+ disabled={saving()}490+ />491+ <button492+ class="peach-additional-url-remove"493+ type="button"494+ aria-label="Remove URL"495+ onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}496+ disabled={saving()}497+ >×</button>498+ </div>499+ )}500+ </For>501+ <button502+ class="peach-add-field-link peach-additional-urls-add"503+ type="button"504+ onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}505+ disabled={saving()}506+ >507+ + add another url508+ </button>509+ </div>459510 </div>460511 </Show>461512 </div>src/popup/views/Vault.tsx+17 −4
@@ -1,6 +1,6 @@11 import { createEffect, createMemo, createSignal, For, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";3-import { getDisplayDomain } from "../../shared/domain";3+import { extractDomain, getDisplayDomain } from "../../shared/domain";44 import { faviconUrl } from "../../shared/favicon";55 66 function FaviconIcon(props: { domain: string; isNote: boolean; isPasskey: boolean }) {@@ -137,6 +137,19 @@137137 return "";138138 }139139 140+ function entryMatchesDomain(entry: VaultEntry, domain: string): boolean {141+ if (!domain) return false;142+ if (entry.type === "passkey") return entry.rpId === domain;143+ if (entry.type !== "login") return false;144+ // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of145+ // each entry URL too so subdomain entries also match.146+ if (entry.url && extractDomain(entry.url) === domain) return true;147+ for (const extra of entry.additionalUrls ?? []) {148+ if (extractDomain(extra) === domain) return true;149+ }150+ return false;151+ }152+140153 const filteredEntries = createMemo(() => {141154 let result = props.entries;142155 @@ -196,8 +209,8 @@196209 }197210 if (tabDomain) {198211 return [...result].sort((a, b) => {199- const aMatch = entryDomain(a) === tabDomain ? 0 : 1;200- const bMatch = entryDomain(b) === tabDomain ? 0 : 1;212+ const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1;213+ const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1;201214 return aMatch - bMatch;202215 });203216 }@@ -417,7 +430,7 @@417430 const isTabMatch = () =>418431 props.currentTabDomain !== undefined &&419432 props.currentTabDomain !== "" &&420- entryDomain(entry) === props.currentTabDomain;433+ entryMatchesDomain(entry, props.currentTabDomain);421434 const isSelected = () => selectedIds().has(entry.id);422435 423436 return (src/shared/types.ts+8 −0
@@ -31,6 +31,13 @@3131 username?: string; // only for login entries3232 password?: string; // only for login entries3333 url?: string; // only for login entries34+ /**35+ * Additional sites this entry should match for autofill. Login entries only.36+ * Each value is a full origin or URL (e.g. `https://login.foo.com`).37+ * The vault domain index keys against the hostname of every entry in this38+ * list, in addition to `url`.39+ */40+ additionalUrls?: string[];3441 totp?: string; // base32 seed, stored encrypted — only for login entries3542 notes?: string;3643 breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries@@ -145,6 +152,7 @@145152 | "RESTORE_ENTRIES"146153 | "CLEAR_VAULT_ENTRIES"147154 | "UPDATE_ENTRY"155+ | "ADD_ADDITIONAL_URL"148156 | "DELETE_ENTRY"149157 | "AUTOFILL_QUERY"150158 | "COPY"