feat(ext/popup): Card view + form (card-silhouette signature, MM/YY selects)
This commit is contained in:
@@ -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<void> {
|
||||
// Tear down any tickers/handlers from a previous detail render before
|
||||
@@ -15,6 +16,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '>').replace(/"/g, '"');
|
||||
return { navigate, setState, sendMessage, getState, escapeHtml };
|
||||
});
|
||||
|
||||
import { renderForm } from '../card';
|
||||
import { sendMessage } from '../../../popup';
|
||||
|
||||
describe('Card save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
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');
|
||||
});
|
||||
});
|
||||
257
extension/src/popup/components/types/card.ts
Normal file
257
extension/src/popup/components/types/card.ts
Normal file
@@ -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<void> {
|
||||
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 = `
|
||||
<div style="font-size:9px;letter-spacing:0.1em;color:#6e7681;margin-bottom:6px;">${escapeHtml(bandLabel)}</div>
|
||||
<div style="font-family:monospace;font-size:14px;letter-spacing:0.08em;color:#c9d1d9;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;" data-field-id="card-number" data-revealed="false" data-field-value="${escapeHtml(number)}" data-field-multiline="false">
|
||||
<span data-field-role="value">${escapeHtml(maskedNumber(number))}</span>
|
||||
${number ? '<button type="button" data-field-action="reveal" style="font-size:10px;color:#8b949e;cursor:pointer;font-family:system-ui,sans-serif;letter-spacing:0;background:transparent;border:0;">show</button>' : ''}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-end;">
|
||||
<div>
|
||||
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">HOLDER</div>
|
||||
<div style="font-size:11px;color:#c9d1d9;">${escapeHtml(c.holder ?? '')}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">EXPIRES</div>
|
||||
<div style="font-family:monospace;font-size:11px;color:#c9d1d9;">${escapeHtml(formatExpiry(c.expiry))}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
|
||||
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
|
||||
</div>
|
||||
${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 }) : ''}
|
||||
<div class="form-actions" style="margin-top:14px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn" id="edit-btn">edit</button>
|
||||
<button class="btn danger" id="trash-btn">trash</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 `<option value="${m}" ${sel}>${m}</option>`;
|
||||
}).join('');
|
||||
const yearOptions = Array.from({ length: 51 }, (_, i) => {
|
||||
const y = currentYear - 25 + i;
|
||||
const sel = c?.expiry?.year === y ? 'selected' : '';
|
||||
return `<option value="${y}" ${sel}>${y}</option>`;
|
||||
}).join('');
|
||||
const kindOptions = CARD_KINDS.map((k) => {
|
||||
const sel = (c?.kind ?? 'credit') === k ? 'selected' : '';
|
||||
return `<option value="${k}" ${sel}>${k}</option>`;
|
||||
}).join('');
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new card' : 'edit card'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title *</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div>
|
||||
<div class="form-group"><label class="label" for="f-number">number</label>
|
||||
<input id="f-number" type="text" inputmode="numeric" value="${escapeHtml(c?.number ?? '')}" placeholder="378282246310005"></div>
|
||||
<div class="form-group"><label class="label" for="f-holder">holder</label>
|
||||
<input id="f-holder" type="text" value="${escapeHtml(c?.holder ?? '')}" placeholder="AARON LEE"></div>
|
||||
<div class="form-group"><label class="label">expiry</label>
|
||||
<div class="inline-row">
|
||||
<select id="f-expiry-month"><option value="">mm</option>${monthOptions}</select>
|
||||
<select id="f-expiry-year"><option value="">yyyy</option>${yearOptions}</select>
|
||||
</div></div>
|
||||
<div class="form-group"><label class="label" for="f-cvv">cvv</label>
|
||||
<input id="f-cvv" type="password" inputmode="numeric" maxlength="4" value="${escapeHtml(c?.cvv ?? '')}"></div>
|
||||
<div class="form-group"><label class="label" for="f-pin">pin</label>
|
||||
<input id="f-pin" type="password" inputmode="numeric" maxlength="8" value="${escapeHtml(c?.pin ?? '')}"></div>
|
||||
<div class="form-group"><label class="label" for="f-kind">kind</label>
|
||||
<select id="f-kind">${kindOptions}</select></div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">save</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user