1a7d31
feat: credit and debit card support — vault, autofill, save offer, decline memory (v1.7.0)
- time
- 2026-04-23 22:50 UTC · 6 months ago
- machine
- koh2
- parent
- 271721
↓ peach-browser-1a7d31.tar.gzatlas at this save
files changed16 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+14 −2src/background/vault.test.ts+84 −0src/background/vault.ts+82 −1src/content/detect.test.ts+38 −0src/content/detect.ts+45 −0src/content/fill.test.ts+66 −1src/content/fill.ts+52 −0src/crypto/cjkRoundTrip.test.ts+39 −0src/popup/styles/popup.css+26 −0src/popup/views/Add.tsx+139 −2src/popup/views/Entry.tsx+132 −4src/popup/views/Settings.tsx+28 −1src/shared/types.ts+23 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "1.6.0",4+ "version": "1.7.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.6.0",4+ "version": "1.7.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.6.0",3+ "version": "1.7.0",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/messages.ts+14 −2
@@ -165,7 +165,7 @@165165 const matches = includeAll166166 ? vault167167 .getEntries()168- .filter((entry) => entry.type !== "note" && entry.type !== "passkey")168+ .filter((entry) => entry.type !== "note" && entry.type !== "passkey" && entry.type !== "card")169169 : vault.queryDomain(domain);170170 return {171171 ok: true,@@ -432,6 +432,16 @@432432 });433433 return { ok: true };434434 }435+ case "DECLINE_CARD_SAVE": {436+ const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };437+ await vault.declineCardSaveOffer(last4, expMonth, expYear);438+ return { ok: true };439+ }440+ case "IS_CARD_DECLINED": {441+ const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };442+ const declined = await vault.isCardDeclined(last4, expMonth, expYear);443+ return { ok: true, data: declined };444+ }435445 default:436446 return { ok: false, error: "unknown message type" };437447 }@@ -463,7 +473,9 @@463473 type !== "LOCK" &&464474 type !== "UPDATE_SETTINGS" &&465475 type !== "GET_PENDING_PASSKEY" &&466- type !== "GET_PASSKEY_PROVIDER_STATE"476+ type !== "GET_PASSKEY_PROVIDER_STATE" &&477+ type !== "DECLINE_CARD_SAVE" &&478+ type !== "IS_CARD_DECLINED"467479 );468480 }469481 src/background/vault.test.ts+84 −0
@@ -744,4 +744,88 @@744744 "STORAGE_QUOTA_EXCEEDED"745745 );746746 });747+748+ it("card entry round-trips through vault — all fields preserved", async () => {749+ const vault = await import("./vault");750+751+ await vault.createVault("card-test-pass-12345");752+ const entry = await vault.addEntry({753+ type: "card",754+ title: "Personal Visa",755+ cardholderName: "Jane Smith",756+ cardNumber: "4111111111111111",757+ expMonth: "05",758+ expYear: "2028",759+ cvc: "123",760+ cardNickname: "Personal Visa",761+ network: "visa",762+ last4: "1111",763+ });764+765+ expect(entry.type).toBe("card");766+ expect(entry.cardholderName).toBe("Jane Smith");767+ expect(entry.cardNumber).toBe("4111111111111111");768+ expect(entry.expMonth).toBe("05");769+ expect(entry.expYear).toBe("2028");770+ expect(entry.cvc).toBe("123");771+ expect(entry.network).toBe("visa");772+ expect(entry.last4).toBe("1111");773+774+ vault.lock();775+ await vault.unlock("card-test-pass-12345");776+ const entries = vault.getEntries();777+ expect(entries).toHaveLength(1);778+ expect(entries[0].cardNumber).toBe("4111111111111111");779+ expect(entries[0].cvc).toBe("123");780+ });781+782+ it("updateEntry — cannot clear card number on card entry", async () => {783+ const vault = await import("./vault");784+785+ await vault.createVault("card-guard-pass-12345");786+ const entry = await vault.addEntry({787+ type: "card",788+ title: "My Card",789+ cardholderName: "Jane Smith",790+ cardNumber: "4111111111111111",791+ expMonth: "05",792+ expYear: "2028",793+ cvc: "123",794+ });795+796+ await expect(797+ vault.updateEntry(entry.id, { cardNumber: "" })798+ ).rejects.toThrow("card number is required for card entries");799+800+ const entries = vault.getEntries();801+ expect(entries[0].cardNumber).toBe("4111111111111111");802+ });803+804+ it("declineCardSaveOffer — stores fingerprint in settings", async () => {805+ const vault = await import("./vault");806+807+ await vault.createVault("decline-pass-12345");808+ await vault.declineCardSaveOffer("1111", "05", "2028");809+810+ const settings = vault.getSettings();811+ expect(settings.declinedCardFingerprints).toHaveLength(1);812+ });813+814+ it("isCardDeclined — returns true for previously declined card", async () => {815+ const vault = await import("./vault");816+817+ await vault.createVault("declined-check-pass-12345");818+ await vault.declineCardSaveOffer("1111", "05", "2028");819+820+ expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);821+ });822+823+ it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {824+ const vault = await import("./vault");825+826+ await vault.createVault("declined-diff-pass-12345");827+ await vault.declineCardSaveOffer("1111", "05", "2028");828+829+ expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);830+ });747831 });src/background/vault.ts+82 −1
@@ -56,6 +56,7 @@5656 passkeyProviderEnabled?: boolean;5757 trashRetentionDays?: number;5858 manualSummonDomains?: Record<string, ManualSummonRecord>;59+ declinedCardFingerprints?: string[];5960 }6061 6162 let settings: VaultSettings = {};@@ -122,6 +123,28 @@122123 }123124 }124125 126+export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {127+ const fingerprint = await cardFingerprint(last4, expMonth, expYear);128+ await loadSettings();129+ settings = {130+ ...settings,131+ declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],132+ };133+ await persistSettings();134+}135+136+export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {137+ await loadSettings();138+ const fingerprint = await cardFingerprint(last4, expMonth, expYear);139+ return (settings.declinedCardFingerprints ?? []).includes(fingerprint);140+}141+142+async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {143+ const encoder = new TextEncoder();144+ const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));145+ return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");146+}147+125148 function hex(buf: Uint8Array): string {126149 return Array.from(buf)127150 .map((b) => b.toString(16).padStart(2, "0"))@@ -294,7 +317,7 @@294317 295318 for (const entry of all) {296319 if (entry.deletedAt) continue;297- if (entry.type === "note" || entry.type === "passkey") continue;320+ if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;298321 299322 const domain = extractDomain(entry.url ?? "");300323 if (!domain) continue;@@ -707,6 +730,12 @@707730 throw new Error("password is required for login entries");708731 }709732 733+ // Card entries must retain their card number734+ if (entries[idx].type === "card" && !entries[idx].cardNumber) {735+ entries[idx] = current;736+ throw new Error("card number is required for card entries");737+ }738+710739 await persistEntries();711740 712741 // Fire-and-forget breach check when password changes (if monitoring enabled and entry is a login)@@ -856,6 +885,11 @@856885 throw new Error("invalid passkey entry");857886 }858887 }888+ if (type === "card") {889+ if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {890+ throw new Error("cardholder name and card number are required");891+ }892+ }859893 860894 return {861895 type,@@ -867,6 +901,7 @@867901 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,868902 breach: type === "login" ? entry.breach : undefined,869903 canary: type === "login" ? entry.canary : undefined,904+ tags: entry.tags,870905 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,871906 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,872907 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,@@ -881,6 +916,14 @@881916 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,882917 backupState: type === "passkey" ? entry.backupState === true : undefined,883918 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,919+ cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,920+ cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,921+ expMonth: type === "card" ? entry.expMonth?.trim() : undefined,922+ expYear: type === "card" ? entry.expYear?.trim() : undefined,923+ cvc: type === "card" ? entry.cvc?.trim() : undefined,924+ cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,925+ network: type === "card" ? entry.network : undefined,926+ last4: type === "card" ? entry.last4?.trim() : undefined,884927 };885928 }886929 @@ -983,6 +1026,36 @@9831026 nextPatch.lastUsedAt = patch.lastUsedAt;9841027 }9851028 1029+ if ("tags" in patch) {1030+ nextPatch.tags = patch.tags;1031+ }1032+1033+ if ("cardholderName" in patch) {1034+ nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1035+ }1036+ if ("cardNumber" in patch) {1037+ const n = patch.cardNumber ?? "";1038+ nextPatch.cardNumber = n || undefined;1039+ }1040+ if ("expMonth" in patch) {1041+ nextPatch.expMonth = patch.expMonth?.trim() || undefined;1042+ }1043+ if ("expYear" in patch) {1044+ nextPatch.expYear = patch.expYear?.trim() || undefined;1045+ }1046+ if ("cvc" in patch) {1047+ nextPatch.cvc = patch.cvc?.trim() || undefined;1048+ }1049+ if ("cardNickname" in patch) {1050+ nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1051+ }1052+ if ("network" in patch) {1053+ nextPatch.network = patch.network;1054+ }1055+ if ("last4" in patch) {1056+ nextPatch.last4 = patch.last4?.trim() || undefined;1057+ }1058+9861059 return nextPatch;9871060 }9881061 @@ -1020,6 +1093,14 @@10201093 modifiedAt,10211094 deletedAt: entry.deletedAt,10221095 tags: "tags" in entry ? entry.tags : undefined,1096+ cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1097+ cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1098+ expMonth: "expMonth" in entry ? entry.expMonth : undefined,1099+ expYear: "expYear" in entry ? entry.expYear : undefined,1100+ cvc: "cvc" in entry ? entry.cvc : undefined,1101+ cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1102+ network: "network" in entry ? entry.network : undefined,1103+ last4: "last4" in entry ? entry.last4 : undefined,10231104 };10241105 }10251106 src/content/detect.test.ts+38 −0
@@ -3,8 +3,10 @@33 import { beforeEach, describe, expect, it } from "vitest";44 55 import {6+ detectCardForm,67 detectLoginFields,78 getPageDomain,9+ hasPaymentProcessorIframe,810 isLoginForm,911 isSignupForm,1012 } from "./detect";@@ -92,5 +94,41 @@9294 9395 it("getPageDomain falls back to the current hostname when no registrable domain exists", () => {9496 expect(getPageDomain()).toBe(window.location.hostname);97+ });98+});99+100+describe("card form detection", () => {101+ beforeEach(() => {102+ document.body.innerHTML = "";103+ });104+105+ it("detectCardForm — finds number field by autocomplete attribute", () => {106+ document.body.innerHTML = '<input autocomplete="cc-number" /><input autocomplete="cc-exp-month" /><input autocomplete="cc-exp-year" /><input autocomplete="cc-csc" />';107+ const result = detectCardForm(document);108+ expect(result).not.toBeNull();109+ expect(result!.number).toBeTruthy();110+ expect(result!.expMonth).toBeTruthy();111+ });112+113+ it("detectCardForm — finds fields by name/id heuristics when no autocomplete", () => {114+ document.body.innerHTML = '<input name="cardnum" /><input name="expiry" /><input name="cvv" />';115+ const result = detectCardForm(document);116+ expect(result).not.toBeNull();117+ expect(result!.number).toBeTruthy();118+ });119+120+ it("detectCardForm — returns null when no card number field found", () => {121+ document.body.innerHTML = '<input type="text" name="username" />';122+ expect(detectCardForm(document)).toBeNull();123+ });124+125+ it("hasPaymentProcessorIframe — returns true when Stripe iframe present", () => {126+ document.body.innerHTML = '<iframe src="https://js.stripe.com/v3/"></iframe>';127+ expect(hasPaymentProcessorIframe()).toBe(true);128+ });129+130+ it("hasPaymentProcessorIframe — returns false on standard checkout page", () => {131+ document.body.innerHTML = '<input name="cardnum" />';132+ expect(hasPaymentProcessorIframe()).toBe(false);95133 });96134 });src/content/detect.ts+45 −0
@@ -171,3 +171,48 @@171171 export function getPageDomain(): string {172172 return extractDomain(location.hostname) ?? location.hostname;173173 }174+175+export interface CardFormFields {176+ number: HTMLInputElement | null;177+ expMonth: HTMLInputElement | null;178+ expYear: HTMLInputElement | null;179+ expiryCombo: HTMLInputElement | null;180+ cvc: HTMLInputElement | null;181+ name: HTMLInputElement | null;182+}183+184+export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null {185+ const numberByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-number"]');186+ const expByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp"]');187+ const expMonthByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-month"]');188+ const expYearByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-exp-year"]');189+ const cvcByAC = root.querySelector<HTMLInputElement>(190+ 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]'191+ );192+ const nameByAC = root.querySelector<HTMLInputElement>('input[autocomplete="cc-name"]');193+194+ if (numberByAC) {195+ return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC };196+ }197+198+ const allInputs = Array.from(root.querySelectorAll<HTMLInputElement>('input'));199+ const numberField = allInputs.find(i =>200+ /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder)201+ ) ?? null;202+ const cvcField = allInputs.find(i =>203+ /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder)204+ ) ?? null;205+ const expField = allInputs.find(i =>206+ /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder)207+ ) ?? null;208+209+ if (!numberField) return null;210+ return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null };211+}212+213+const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com'];214+215+export function hasPaymentProcessorIframe(): boolean {216+ const iframes = Array.from(document.querySelectorAll('iframe'));217+ return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host)));218+}src/content/fill.test.ts+66 −1
@@ -2,7 +2,7 @@22 33 import { beforeEach, describe, expect, it, vi } from "vitest";44 5-import { fillFields } from "./fill";5+import { fillFields, fillCardForm, formatCardNumber } from "./fill";66 77 describe("content fill", () => {88 beforeEach(() => {@@ -88,5 +88,70 @@8888 expect(usernameDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);8989 expect(passwordDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);9090 expect(passwordDispatch.mock.calls.every(([event]) => !(event instanceof CustomEvent))).toBe(true);91+ });92+});93+94+describe("card fill", () => {95+ beforeEach(() => {96+ document.body.innerHTML = "";97+ });98+99+ function makeInput(attrs: Record<string, string> = {}): HTMLInputElement {100+ const input = document.createElement("input");101+ for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);102+ document.body.appendChild(input);103+ return input;104+ }105+106+ it("fillCardForm — fills number, expiry, CVC, and name fields", () => {107+ const number = makeInput({ type: "text" });108+ const expMonth = makeInput({ type: "text" });109+ const expYear = makeInput({ type: "text" });110+ const cvc = makeInput({ type: "text" });111+ const name = makeInput({ type: "text" });112+113+ fillCardForm(114+ { number, expMonth, expYear, expiryCombo: null, cvc, name },115+ { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane Smith" }116+ );117+118+ expect(number.value).toBe("4111 1111 1111 1111");119+ expect(expMonth.value).toBe("05");120+ expect(expYear.value).toBe("2028");121+ expect(cvc.value).toBe("123");122+ expect(name.value).toBe("Jane Smith");123+ });124+125+ it("fillCardForm — handles combined expiry field (MM/YY)", () => {126+ const number = makeInput({ type: "text" });127+ const expiryCombo = makeInput({ type: "text" });128+ const cvc = makeInput({ type: "text" });129+130+ fillCardForm(131+ { number, expMonth: null, expYear: null, expiryCombo, cvc, name: null },132+ { cardNumber: "5555555555554444", expMonth: "12", expYear: "2026", cvc: "456", cardholderName: "John Doe" }133+ );134+135+ expect(expiryCombo.value).toBe("12/26");136+ });137+138+ it("fillCardForm — dispatches input and change events for React compatibility", () => {139+ const number = makeInput({ type: "text" });140+ const numberDispatch = vi.spyOn(number, "dispatchEvent");141+142+ fillCardForm(143+ { number, expMonth: null, expYear: null, expiryCombo: null, cvc: null, name: null },144+ { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane" }145+ );146+147+ expect(numberDispatch.mock.calls.map(([event]) => event.type)).toEqual(["input", "change"]);148+ });149+150+ it("formatCardNumber — formats Visa as 4-4-4-4", () => {151+ expect(formatCardNumber("4111111111111111")).toBe("4111 1111 1111 1111");152+ });153+154+ it("formatCardNumber — formats Amex as 4-6-5", () => {155+ expect(formatCardNumber("378282246310005")).toBe("3782 822463 10005");91156 });92157 });src/content/fill.ts+52 −0
@@ -47,3 +47,55 @@4747 el.dispatchEvent(new Event("input", { bubbles: true }));4848 el.dispatchEvent(new Event("change", { bubbles: true }));4949 }50+51+export interface CardFillFields {52+ number: HTMLInputElement | null;53+ expMonth: HTMLInputElement | null;54+ expYear: HTMLInputElement | null;55+ expiryCombo: HTMLInputElement | null;56+ cvc: HTMLInputElement | null;57+ name: HTMLInputElement | null;58+}59+60+export function fillCardForm(fields: CardFillFields, card: { cardNumber: string; expMonth: string; expYear: string; cvc: string; cardholderName: string }): void {61+ const raw = card.cardNumber.replace(/\s/g, '');62+ if (fields.number) setNativeValue(fields.number, formatCardNumber(raw));63+ if (fields.expiryCombo) {64+ setNativeValue(fields.expiryCombo, `${card.expMonth}/${card.expYear.slice(-2)}`);65+ } else {66+ if (fields.expMonth) setNativeValue(fields.expMonth, card.expMonth);67+ if (fields.expYear) setNativeValue(fields.expYear, card.expYear);68+ }69+ if (fields.cvc) setNativeValue(fields.cvc, card.cvc);70+ if (fields.name) setNativeValue(fields.name, card.cardholderName);71+}72+73+export function formatCardNumber(raw: string): string {74+ if (/^3[47]/.test(raw)) return raw.replace(/(\d{4})(\d{6})(\d{5})/, '$1 $2 $3');75+ return raw.replace(/(\d{4})(?=\d)/g, '$1 ').trim();76+}77+78+export function detectCardNetwork(number: string): import('../shared/types').CardNetwork {79+ const n = number.replace(/\s/g, '');80+ if (/^4/.test(n)) return 'visa';81+ if (/^5[1-5]|^2[2-7]/.test(n)) return 'mastercard';82+ if (/^3[47]/.test(n)) return 'amex';83+ if (/^6(?:011|5)/.test(n)) return 'discover';84+ return 'unknown';85+}86+87+export function passesLuhn(number: string): boolean {88+ const digits = number.replace(/\s/g, '').split('').map(Number);89+ let sum = 0, isEven = false;90+ for (let i = digits.length - 1; i >= 0; i--) {91+ let d = digits[i]!;92+ if (isEven) { d *= 2; if (d > 9) d -= 9; }93+ sum += d;94+ isEven = !isEven;95+ }96+ return sum % 10 === 0;97+}98+99+export function extractLast4(number: string): string {100+ return number.replace(/\s/g, '').slice(-4);101+}src/crypto/cjkRoundTrip.test.ts+39 −0
@@ -260,6 +260,45 @@260260 /HEADER_COUNT_MISMATCH/261261 );262262 }, 30000);263+264+ it("CJK round-trip preserves card entries with all fields intact", async () => {265+ const cardEntry: VaultEntry = {266+ id: "card-one",267+ type: "card",268+ title: "Personal Visa",269+ cardholderName: "Jane Smith",270+ cardNumber: "4111111111111111",271+ expMonth: "05",272+ expYear: "2028",273+ cvc: "123",274+ cardNickname: "Everyday Card",275+ network: "visa",276+ last4: "1111",277+ createdAt: 1_700_000_005_000,278+ modifiedAt: 1_700_000_005_000,279+ };280+281+ const { phrase } = await exportToCJK({282+ entries: [cardEntry],283+ settings: {},284+ passphrase,285+ argon2: FAST_ARGON2,286+ });287+288+ const { entries: restored } = await importFromCJK(phrase, passphrase);289+ expect(restored).toHaveLength(1);290+291+ const card = restored[0];292+ expect(card.type).toBe("card");293+ expect(card.cardholderName).toBe("Jane Smith");294+ expect(card.cardNumber).toBe("4111111111111111");295+ expect(card.expMonth).toBe("05");296+ expect(card.expYear).toBe("2028");297+ expect(card.cvc).toBe("123");298+ expect(card.cardNickname).toBe("Everyday Card");299+ expect(card.network).toBe("visa");300+ expect(card.last4).toBe("1111");301+ }, 30000);263302 });264303 265304 describe("CJK phrase size sanity (§10.4)", () => {src/popup/styles/popup.css+26 −0
@@ -4408,3 +4408,29 @@44084408 border-color: var(--peach-t0);44094409 transform: scale(1.1);44104410 }4411+4412+/* ── Card entry styles ───────────────────────────────────────── */4413+.peach-card-number {4414+ font-family: var(--peach-mono);4415+ letter-spacing: 0.08em;4416+}4417+4418+.peach-card-network-badge {4419+ font-size: 10px;4420+ padding: 1px 5px;4421+ border-radius: 4px;4422+ border: 0.5px solid var(--peach-border);4423+ color: var(--peach-text-dim);4424+ margin-left: 6px;4425+ vertical-align: middle;4426+}4427+4428+.peach-card-expiry-row {4429+ display: flex;4430+ align-items: center;4431+ gap: 8px;4432+}4433+4434+.peach-card-expiry-sep {4435+ color: var(--peach-text-dim);4436+}src/popup/views/Add.tsx+139 −2
@@ -33,7 +33,20 @@3333 const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));3434 3535 const isNoteEntry = () => props.entry?.type === "note";36- const [selectedType, setSelectedType] = createSignal<"login" | "note">(isNoteEntry() ? "note" : "login");36+ const isCardEntry = () => props.entry?.type === "card";37+ const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(38+ isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"39+ );40+41+ // Card signals42+ const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");43+ const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");44+ const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");45+ const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");46+ const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");47+ const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");48+ const [showCvc, setShowCvc] = createSignal(false);49+ const [cardNumberFocused, setCardNumberFocused] = createSignal(false);3750 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);3851 3952 function noteExec(cmd: string, val?: string) {@@ -41,9 +54,26 @@4154 noteEditorEl()?.focus();4255 }4356 57+ function getCardNetwork(num: string): string {58+ const n = num.replace(/\s/g, '');59+ if (/^4/.test(n)) return 'Visa';60+ if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';61+ if (/^3[47]/.test(n)) return 'Amex';62+ if (/^6(?:011|5)/.test(n)) return 'Discover';63+ return '';64+ }65+66+ function formatCardNumberDisplay(raw: string, focused: boolean): string {67+ const n = raw.replace(/\s/g, '');68+ if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);69+ if (/^3[47]/.test(n)) return n.replace(/(\d{4})(\d{1,6})?(\d{1,5})?/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));70+ return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();71+ }72+4473 const canSave = () => {4574 if (!title().trim()) return false;4675 if (selectedType() === "login" && !password().trim()) return false;76+ if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;4777 return true;4878 };4979 @@ -115,7 +145,13 @@115145 116146 async function handleSave() {117147 if (!canSave()) {118- setError(selectedType() === "note" ? "title is required" : "title and password are required");148+ setError(149+ selectedType() === "note"150+ ? "title is required"151+ : selectedType() === "card"152+ ? "cardholder name and card number are required"153+ : "title and password are required"154+ );119155 return;120156 }121157 @@ -136,6 +172,19 @@136172 payload.totp = getNormalizedTotp();137173 }138174 175+ if (selectedType() === "card") {176+ const raw = cardNumber().replace(/\s/g, '');177+ payload.cardholderName = cardholderName().trim();178+ payload.cardNumber = raw;179+ payload.expMonth = expMonth().trim();180+ payload.expYear = expYear().trim();181+ payload.cvc = cvc().trim();182+ payload.cardNickname = cardNickname().trim() || undefined;183+ const n = raw;184+ payload.network = /^4/.test(n) ? 'visa' : /^5[1-5]|^2[2-7]/.test(n) ? 'mastercard' : /^3[47]/.test(n) ? 'amex' : /^6(?:011|5)/.test(n) ? 'discover' : 'unknown';185+ payload.last4 = raw.slice(-4);186+ }187+139188 if (props.entry) {140189 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });141190 const result = res as { ok: boolean; error?: string };@@ -191,6 +240,10 @@191240 class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}192241 onClick={() => setSelectedType("note")}193242 >note</button>243+ <button244+ class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}245+ onClick={() => setSelectedType("card")}246+ >card</button>194247 </div>195248 </Show>196249 @@ -321,6 +374,90 @@321374 </div>322375 </div>323376 </Show>377+ </section>378+ </Show>379+380+ {/* ── Card fields ── */}381+ <Show when={selectedType() === "card"}>382+ <section class="peach-fields">383+ {/* Nickname */}384+ <div class="peach-field-card">385+ <span class="peach-label">nickname</span>386+ <div class="peach-field-value-row">387+ <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"388+ autocomplete="off" value={cardNickname()}389+ onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />390+ </div>391+ </div>392+ {/* Cardholder name */}393+ <div class="peach-field-card">394+ <span class="peach-label">cardholder name</span>395+ <div class="peach-field-value-row">396+ <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"397+ autocomplete="cc-name" value={cardholderName()}398+ onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />399+ </div>400+ </div>401+ {/* Card number */}402+ <div class="peach-field-card">403+ <span class="peach-label">404+ card number405+ <Show when={getCardNetwork(cardNumber())}>406+ <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>407+ </Show>408+ </span>409+ <div class="peach-field-value-row">410+ <input class="peach-field-value peach-field-value--edit peach-card-number"411+ placeholder="•••• •••• •••• ••••"412+ autocomplete="cc-number" inputmode="numeric" maxLength={19}413+ value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}414+ onFocus={() => setCardNumberFocused(true)}415+ onBlur={() => setCardNumberFocused(false)}416+ onInput={(e) => {417+ const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');418+ setCardNumber(raw);419+ }} />420+ </div>421+ </div>422+ {/* Expiry */}423+ <div class="peach-field-card">424+ <span class="peach-label">expiry</span>425+ <div class="peach-card-expiry-row">426+ <input class="peach-field-value peach-field-value--edit" placeholder="MM"427+ autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"428+ value={expMonth()}429+ onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />430+ <span class="peach-card-expiry-sep">/</span>431+ <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"432+ autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"433+ value={expYear()}434+ onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />435+ </div>436+ </div>437+ {/* CVC */}438+ <div class="peach-field-card">439+ <span class="peach-label">CVC</span>440+ <div class="peach-field-value-row">441+ <input class="peach-field-value peach-field-value--edit"442+ type={showCvc() ? "text" : "password"} placeholder="•••"443+ autocomplete="cc-csc" inputmode="numeric" maxLength={4}444+ value={cvc()}445+ onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />446+ <button class="peach-inline-action peach-password-toggle-btn" type="button"447+ onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>448+ <Show when={showCvc()} fallback={449+ <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">450+ <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />451+ <circle cx="7" cy="5" r="1.75" />452+ </svg>453+ }>454+ <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">455+ <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />456+ </svg>457+ </Show>458+ </button>459+ </div>460+ </div>324461 </section>325462 </Show>326463 src/popup/views/Entry.tsx+132 −4
@@ -24,6 +24,8 @@2424 const [showPassword, setShowPassword] = createSignal(false);2525 const [showGenerator, setShowGenerator] = createSignal(false);2626 const [copied, setCopied] = createSignal<string | null>(null);27+ const [showCardNumber, setShowCardNumber] = createSignal(false);28+ const [showCardCvc, setShowCardCvc] = createSignal(false);2729 const [totpTick, setTotpTick] = createSignal(0);2830 const [confirmDelete, setConfirmDelete] = createSignal(false);2931 const [totpHot, setTotpHot] = createSignal(false);@@ -314,7 +316,7 @@314316 {(entry) => (315317 <>316318 {/* ── Breach strip ─────────────────────────────────── */}317- <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>319+ <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>318320 <div class="peach-breach-badge">319321 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">320322 <path d="M6 1L11 10H1L6 1z" />@@ -418,7 +420,7 @@418420 </div>419421 420422 {/* ── Domain / URL ─────────────────────────────────── */}421- <Show when={entry().type !== "note"}>423+ <Show when={entry().type !== "note" && entry().type !== "card"}>422424 <div class="peach-domain" style="position:relative;z-index:1;">423425 <Show424426 when={editingField() === "url"}@@ -483,8 +485,134 @@483485 </section>484486 </Show>485487 488+ {/* ── Card detail view ─────────────────────────────── */}489+ <Show when={entry().type === "card"}>490+ <section class="peach-fields" style="position:relative;z-index:1;">491+ {/* Card subtitle: •••• last4 */}492+ <Show when={entry().last4}>493+ <div class="peach-field-card" style="padding-bottom:4px;">494+ <span class="peach-label">495+ <Show when={entry().network && entry().network !== "unknown"}>496+ <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>497+ </Show>498+ {entry().cardNickname || `•••• ${entry().last4}`}499+ </span>500+ </div>501+ </Show>502+503+ {/* Cardholder name */}504+ <div class="peach-field-card">505+ <span class="peach-label">cardholder name</span>506+ <div class="peach-field-value-row">507+ <span class="peach-field-value">{entry().cardholderName || "—"}</span>508+ <Show when={entry().cardholderName}>509+ <button510+ class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}511+ onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}512+ aria-label="Copy cardholder name"513+ >514+ <CopyGlyph /><span class="peach-copy-sheen" />515+ </button>516+ </Show>517+ </div>518+ </div>519+520+ {/* Card number */}521+ <div class="peach-field-card">522+ <span class="peach-label">card number</span>523+ <div class="peach-field-value-row">524+ <span class={`peach-field-value peach-card-number`}>525+ <Show when={showCardNumber()} fallback={526+ <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>527+ }>528+ <span>{entry().cardNumber ?? ""}</span>529+ </Show>530+ </span>531+ <div class="peach-field-actions">532+ <button533+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"534+ onClick={() => setShowCardNumber(!showCardNumber())}535+ aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}536+ >537+ <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>538+ <EyeClosedGlyph />539+ </Show>540+ </button>541+ <Show when={entry().cardNumber}>542+ <button543+ class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}544+ onClick={(e) => {545+ const cardNum = entry().cardNumber!;546+ if (entry().canary?.webhookUrl) {547+ void sendMessage({548+ type: "FIRE_CANARY_PING",549+ payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },550+ });551+ }552+ void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);553+ }}554+ aria-label="Copy card number"555+ >556+ <CopyGlyph /><span class="peach-copy-sheen" />557+ </button>558+ </Show>559+ </div>560+ </div>561+ </div>562+563+ {/* Expiry */}564+ <Show when={entry().expMonth || entry().expYear}>565+ <div class="peach-field-card">566+ <span class="peach-label">expiry</span>567+ <div class="peach-field-value-row">568+ <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>569+ <button570+ class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}571+ onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}572+ aria-label="Copy expiry"573+ >574+ <CopyGlyph /><span class="peach-copy-sheen" />575+ </button>576+ </div>577+ </div>578+ </Show>579+580+ {/* CVC */}581+ <Show when={entry().cvc}>582+ <div class="peach-field-card">583+ <span class="peach-label">CVC</span>584+ <div class="peach-field-value-row">585+ <span class="peach-field-value">586+ <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>587+ <span>{entry().cvc}</span>588+ </Show>589+ </span>590+ <div class="peach-field-actions">591+ <button592+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"593+ onClick={() => setShowCardCvc(!showCardCvc())}594+ aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}595+ >596+ <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>597+ <EyeClosedGlyph />598+ </Show>599+ </button>600+ <button601+ class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}602+ onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}603+ aria-label="Copy CVC"604+ >605+ <CopyGlyph /><span class="peach-copy-sheen" />606+ </button>607+ </div>608+ </div>609+ </div>610+ </Show>611+ </section>612+ </Show>613+486614 {/* ── Login fields ─────────────────────────────────── */}487- <Show when={entry().type !== "note"}>615+ <Show when={entry().type !== "note" && entry().type !== "card"}>488616 <section class="peach-fields" style="position:relative;z-index:1;">489617 490618 {/* Username */}@@ -631,7 +759,7 @@631759 </Show>632760 633761 {/* ── Notes (on login entries) ──────────────────────── */}634- <Show when={entry().type !== "note"}>762+ <Show when={entry().type !== "note" && entry().type !== "card"}>635763 <Show636764 when={entry().notes || editingField() === "notes"}637765 fallback={src/popup/views/Settings.tsx+28 −1
@@ -58,6 +58,9 @@5858 const [dedupRunning, setDedupRunning] = createSignal(false);5959 const [dedupDone, setDedupDone] = createSignal<number | null>(null);6060 61+ // Declined card fingerprints62+ const [declinedCardCount, setDeclinedCardCount] = createSignal(0);63+6164 // Change master password state6265 const [showChangePw, setShowChangePw] = createSignal(false);6366 const [changePwCurrent, setChangePwCurrent] = createSignal("");@@ -99,11 +102,12 @@99102 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);100103 101104 onMount(async () => {102- const [licenseRes, syncRes, passkeyRes, duressRes] = await Promise.all([105+ const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([103106 sendMessage({ type: "GET_LICENSE" }),104107 sendMessage({ type: "GET_SYNC_STATE" }),105108 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),106109 sendMessage({ type: "IS_DURESS_ENABLED" }),110+ sendMessage({ type: "GET_SETTINGS" }),107111 ]);108112 109113 if ((licenseRes as { ok: boolean }).ok) {@@ -137,6 +141,11 @@137141 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);138142 }139143 144+ if ((settingsRes as { ok: boolean }).ok) {145+ const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;146+ setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);147+ }148+140149 chrome.runtime.onMessage.addListener((msg) => {141150 if (msg.type === "SYNC_STATE_UPDATE") {142151 setPairedDevices(msg.pairedDevices ?? []);@@ -301,6 +310,11 @@301310 }302311 }303312 313+ async function handleClearDeclinedCards() {314+ await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });315+ setDeclinedCardCount(0);316+ }317+304318 async function handleCountDuplicates() {305319 setDedupRunning(true);306320 setDedupDone(null);@@ -608,6 +622,19 @@608622 </a>609623 </div>610624 </div>625+626+ {/* ── Cards ─────────────────────────────────────────────────────── */}627+ <Show when={declinedCardCount() > 0}>628+ <div class="peach-settings-group">629+ <p class="peach-settings-label">cards</p>630+ <div class="peach-settings-row peach-settings-row--meta">631+ <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>632+ <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>633+ Clear634+ </button>635+ </div>636+ </div>637+ </Show>611638 612639 {/* ── Duress password ─────────────────────────────────────────────── */}613640 <div class="peach-settings-group">src/shared/types.ts+23 −1
@@ -11,9 +11,11 @@1111 pingCount?: number;1212 }1313 14+export type CardNetwork = "visa" | "mastercard" | "amex" | "discover" | "unknown";15+1416 export interface VaultEntry {1517 id: string; // crypto.randomUUID()16- type?: "login" | "note" | "passkey"; // defaults to "login" for existing entries18+ type?: "login" | "note" | "passkey" | "card"; // defaults to "login" for existing entries1719 title: string;1820 username?: string; // only for login entries1921 password?: string; // only for login entries@@ -40,6 +42,15 @@4042 backupEligible?: boolean;4143 backupState?: boolean;4244 lastUsedAt?: number;45+ // Card entry fields46+ cardholderName?: string;47+ cardNumber?: string; // full PAN, stored encrypted in vault48+ expMonth?: string;49+ expYear?: string;50+ cvc?: string;51+ cardNickname?: string;52+ network?: CardNetwork;53+ last4?: string;4354 }4455 4556 export interface LegacyVaultEntry {@@ -56,6 +67,15 @@5667 deletedAt?: number;5768 }5869 70+export interface CardEntry extends VaultEntry {71+ type: "card";72+ cardholderName: string;73+ cardNumber: string;74+ expMonth: string;75+ expYear: string;76+ cvc: string;77+}78+5979 export interface PasskeyEntry extends VaultEntry {6080 type: "passkey";6181 rpId: string;@@ -156,6 +176,8 @@156176 | "COUNT_DUPLICATES"157177 | "DEDUPLICATE_ENTRIES"158178 | "RECORD_MANUAL_SUMMON"179+ | "DECLINE_CARD_SAVE"180+ | "IS_CARD_DECLINED"159181 // Content-script-bound message types — sent BY the background TO content160182 // scripts. They never reach messages.ts's switch. Kept in this union so161183 // existing call sites type-check; do not add cases for them in the