From 560a3c63c484a3fbeb2cab225fe86327b7c555a8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 23 Apr 2026 22:39:21 -0400 Subject: [PATCH] feat(ext/popup): Card view + form (card-silhouette signature, MM/YY selects) --- extension/src/popup/components/item-detail.ts | 4 +- extension/src/popup/components/item-form.ts | 4 +- .../types/__tests__/card.save.test.ts | 75 +++++ extension/src/popup/components/types/card.ts | 257 ++++++++++++++++++ 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 extension/src/popup/components/types/__tests__/card.save.test.ts create mode 100644 extension/src/popup/components/types/card.ts diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index c766cad..bdb513a 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -7,6 +7,7 @@ import { getState } from '../popup'; import * as login from './types/login'; import * as secureNote from './types/secure-note'; import * as identity from './types/identity'; +import * as card from './types/card'; export async function renderItemDetail(app: HTMLElement): Promise { // Tear down any tickers/handlers from a previous detail render before @@ -15,6 +16,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { login.teardown(); secureNote.teardown(); identity.teardown(); + card.teardown(); const item = getState().selectedItem; if (!item) { navigate('list'); return; } @@ -23,7 +25,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { case 'login': return login.renderDetail(app, item); case 'secure_note': return secureNote.renderDetail(app, item); case 'identity': return identity.renderDetail(app, item); - case 'card': + case 'card': return card.renderDetail(app, item); case 'key': case 'totp': case 'document': return renderComingSoon(app, item); diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index fa65aaa..4d0253f 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -6,11 +6,13 @@ import type { Item, ItemType } from '../../shared/types'; import * as login from './types/login'; import * as secureNote from './types/secure-note'; import * as identity from './types/identity'; +import * as card from './types/card'; export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { login.teardown(); // detail-view's ticker/listener don't leak into form secureNote.teardown(); identity.teardown(); + card.teardown(); const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; const type: ItemType = existing?.type ?? state.newType ?? 'login'; @@ -19,7 +21,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { case 'login': return login.renderForm(app, mode, existing); case 'secure_note': return secureNote.renderForm(app, mode, existing); case 'identity': return identity.renderForm(app, mode, existing); - case 'card': + case 'card': return card.renderForm(app, mode, existing); case 'key': case 'totp': case 'document': return renderComingSoon(app, type); diff --git a/extension/src/popup/components/types/__tests__/card.save.test.ts b/extension/src/popup/components/types/__tests__/card.save.test.ts new file mode 100644 index 0000000..6bc2854 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/card.save.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../popup', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: 'card', + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../card'; +import { sendMessage } from '../../../popup'; + +describe('Card save shape', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } }); + }); + + it('builds an Item with expiry as { month, year } and kind from select', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'Amex Gold'; + (document.getElementById('f-number') as HTMLInputElement).value = '378282246310005'; + (document.getElementById('f-holder') as HTMLInputElement).value = 'AARON LEE'; + (document.getElementById('f-expiry-month') as HTMLSelectElement).value = '08'; + (document.getElementById('f-expiry-year') as HTMLSelectElement).value = '2029'; + (document.getElementById('f-cvv') as HTMLInputElement).value = '1234'; + (document.getElementById('f-pin') as HTMLInputElement).value = '5678'; + (document.getElementById('f-kind') as HTMLSelectElement).value = 'credit'; + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.type).toBe('card'); + expect(msg.item.core).toMatchObject({ + type: 'card', + number: '378282246310005', + holder: 'AARON LEE', + expiry: { month: 8, year: 2029 }, + cvv: '1234', + pin: '5678', + kind: 'credit', + }); + }); + + it('omits expiry entirely when month or year is empty', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'Loyalty card'; + (document.getElementById('f-kind') as HTMLSelectElement).value = 'loyalty'; + // expiry-month + expiry-year left empty. + + document.getElementById('save-btn')!.click(); + await new Promise(r => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.core.expiry).toBeUndefined(); + expect(msg.item.core.kind).toBe('loyalty'); + }); +}); diff --git a/extension/src/popup/components/types/card.ts b/extension/src/popup/components/types/card.ts new file mode 100644 index 0000000..2c85038 --- /dev/null +++ b/extension/src/popup/components/types/card.ts @@ -0,0 +1,257 @@ +/// Card: number / holder / expiry MonthYear / cvv / pin / kind. +/// Detail view has a styled card-silhouette signature block. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; +import type { Item, ItemId, ManifestEntry, CardKind } from '../../../shared/types'; +import { + renderConcealedRow, renderSignatureBlock, wireFieldHandlers, +} from '../fields'; + +const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other']; + +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +export function teardown(): void { + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } +} + +function brandFromNumber(num: string): string { + if (/^3[47]/.test(num)) return 'AMEX'; + if (/^4/.test(num)) return 'VISA'; + if (/^5[1-5]/.test(num)) return 'MASTERCARD'; + if (/^6/.test(num)) return 'DISCOVER'; + return ''; +} + +function maskedNumber(num: string): string { + if (!num) return ''; + const last4 = num.slice(-4); + const groups = num.length > 4 ? '•••• •••• •••• ' : ''; + return `${groups}${last4}`; +} + +function formatExpiry(e: { month: number; year: number } | undefined): string { + if (!e) return ''; + const mm = String(e.month).padStart(2, '0'); + const yy = String(e.year).slice(-2); + return `${mm}/${yy}`; +} + +export async function renderDetail(app: HTMLElement, item: Item): Promise { + if (item.core.type !== 'card') return; + const c = item.core; + const number = c.number ?? ''; + const brand = brandFromNumber(number); + const kindLabel = (c.kind ?? 'other').toUpperCase(); + const bandLabel = brand ? `${brand} · ${kindLabel}` : kindLabel; + + const sigInner = ` +
${escapeHtml(bandLabel)}
+
+ ${escapeHtml(maskedNumber(number))} + ${number ? '' : ''} +
+
+
+
HOLDER
+
${escapeHtml(c.holder ?? '')}
+
+
+
EXPIRES
+
${escapeHtml(formatExpiry(c.expiry))}
+
+
+ `; + + app.innerHTML = ` +
+
+
${escapeHtml(item.title)}
+ ${renderSignatureBlock({ accent: 'blue', children: sigInner })} +
+ ${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''} + ${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''} +
+ + + +
+
+ `; + + // The card-number reveal lives inside the signature block, so wireFieldHandlers + // picks it up alongside the cvv/pin rows. + wireFieldHandlers(app); + + document.getElementById('back-btn')?.addEventListener('click', () => { teardown(); navigate('list'); }); + document.getElementById('edit-btn')?.addEventListener('click', () => { teardown(); navigate('edit'); }); + document.getElementById('trash-btn')?.addEventListener('click', async () => { + if (!confirm(`Move "${item.title}" to trash?`)) return; + teardown(); + const resp = await sendMessage({ type: 'delete_item', id: item.id }); + if (!resp.ok) { setState({ error: resp.error }); return; } + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + }); + + const handler = async (e: KeyboardEvent) => { + const t = e.target; + if (t instanceof HTMLElement) { + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; + } + switch (e.key) { + case 'Escape': teardown(); navigate('list'); break; + case 'e': teardown(); navigate('edit'); break; + case 'd': + e.preventDefault(); + if (confirm(`Move "${item.title}" to trash?`)) { + teardown(); + const resp = await sendMessage({ type: 'delete_item', id: item.id }); + if (!resp.ok) { setState({ error: resp.error }); return; } + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + } + break; + } + }; + activeKeyHandler = handler; + document.addEventListener('keydown', handler); +} + +export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { + const state = getState(); + const title = existing?.title ?? ''; + const c = (existing?.core.type === 'card') ? existing.core : null; + const currentYear = new Date().getFullYear(); + + const monthOptions = Array.from({ length: 12 }, (_, i) => { + const m = String(i + 1).padStart(2, '0'); + const sel = c?.expiry?.month === i + 1 ? 'selected' : ''; + return ``; + }).join(''); + const yearOptions = Array.from({ length: 51 }, (_, i) => { + const y = currentYear - 25 + i; + const sel = c?.expiry?.year === y ? 'selected' : ''; + return ``; + }).join(''); + const kindOptions = CARD_KINDS.map((k) => { + const sel = (c?.kind ?? 'credit') === k ? 'selected' : ''; + return ``; + }).join(''); + + app.innerHTML = ` +
+
${mode === 'add' ? 'new card' : 'edit card'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+ `; + + document.getElementById('cancel-btn')?.addEventListener('click', () => { + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + }); + document.getElementById('save-btn')?.addEventListener('click', async () => { + await saveCard(mode, existing); + }); + + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + } + }; + document.addEventListener('keydown', escHandler); + + (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); +} + +async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise { + const state = getState(); + const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); + if (!title) { setState({ error: 'Title is required' }); return; } + + const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value.trim(); + const number = get('f-number'); + const holder = get('f-holder'); + const expMonth = get('f-expiry-month'); + const expYear = get('f-expiry-year'); + const cvv = get('f-cvv'); + const pin = get('f-pin'); + const kindRaw = get('f-kind'); + const kind: CardKind = (CARD_KINDS as string[]).includes(kindRaw) ? (kindRaw as CardKind) : 'credit'; + + const expiry = (expMonth && expYear) + ? { month: Number(expMonth), year: Number(expYear) } + : undefined; + + const core = { + type: 'card' as const, + number: number || undefined, + holder: holder || undefined, + expiry, + cvv: cvv || undefined, + pin: pin || undefined, + kind, + }; + + const now = Math.floor(Date.now() / 1000); + const item: Item = { + id: existing?.id ?? '', + title, type: 'card', + tags: existing?.tags ?? [], + favorite: existing?.favorite ?? false, + group: existing?.group, notes: existing?.notes, + created: existing?.created ?? now, + modified: now, trashed_at: undefined, + core, + sections: existing?.sections ?? [], + attachments: existing?.attachments ?? [], + field_history: existing?.field_history ?? {}, + }; + + setState({ loading: true, error: null }); + const resp = mode === 'add' + ? await sendMessage({ type: 'add_item', item }) + : await sendMessage({ type: 'update_item', id: state.selectedId!, item }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + } else { + setState({ loading: false, error: resp.error }); + } +}