From 113b0b690aca3043dc38f777b97e5c3323d5dce4 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 23 Apr 2026 22:29:04 -0400 Subject: [PATCH] feat(ext/popup): Identity view + form (profile-card signature block) --- extension/src/popup/components/item-detail.ts | 4 +- extension/src/popup/components/item-form.ts | 4 +- .../types/__tests__/identity.save.test.ts | 76 +++++++ .../src/popup/components/types/identity.ts | 199 ++++++++++++++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 extension/src/popup/components/types/__tests__/identity.save.test.ts create mode 100644 extension/src/popup/components/types/identity.ts diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index 15e15a3..c766cad 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -6,6 +6,7 @@ import type { Item } from '../../shared/types'; import { getState } from '../popup'; import * as login from './types/login'; import * as secureNote from './types/secure-note'; +import * as identity from './types/identity'; export async function renderItemDetail(app: HTMLElement): Promise { // Tear down any tickers/handlers from a previous detail render before @@ -13,6 +14,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { // call all of them since the dispatcher doesn't know which was active. login.teardown(); secureNote.teardown(); + identity.teardown(); const item = getState().selectedItem; if (!item) { navigate('list'); return; } @@ -20,7 +22,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { switch (item.type) { case 'login': return login.renderDetail(app, item); case 'secure_note': return secureNote.renderDetail(app, item); - case 'identity': + case 'identity': return identity.renderDetail(app, item); case 'card': case 'key': case 'totp': diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 5622f12..fa65aaa 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -5,10 +5,12 @@ import { navigate, getState } from '../popup'; 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'; 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(); const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; const type: ItemType = existing?.type ?? state.newType ?? 'login'; @@ -16,7 +18,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { switch (type) { case 'login': return login.renderForm(app, mode, existing); case 'secure_note': return secureNote.renderForm(app, mode, existing); - case 'identity': + case 'identity': return identity.renderForm(app, mode, existing); case 'card': case 'key': case 'totp': diff --git a/extension/src/popup/components/types/__tests__/identity.save.test.ts b/extension/src/popup/components/types/__tests__/identity.save.test.ts new file mode 100644 index 0000000..2a9b17e --- /dev/null +++ b/extension/src/popup/components/types/__tests__/identity.save.test.ts @@ -0,0 +1,76 @@ +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: 'identity', + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../identity'; +import { sendMessage } from '../../../popup'; + +describe('Identity 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 all populated fields and undefined for blanks', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'Aaron Lee · personal'; + (document.getElementById('f-full-name') as HTMLInputElement).value = 'Aaron Lee'; + (document.getElementById('f-email') as HTMLInputElement).value = 'aaron@example.com'; + (document.getElementById('f-phone') as HTMLInputElement).value = '+1 555 0100'; + (document.getElementById('f-address') as HTMLTextAreaElement).value = '1 Main St\nSpringfield'; + (document.getElementById('f-dob') as HTMLInputElement).value = '1985-05-23'; + + 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('identity'); + expect(msg.item.core).toEqual({ + type: 'identity', + full_name: 'Aaron Lee', + email: 'aaron@example.com', + phone: '+1 555 0100', + address: '1 Main St\nSpringfield', + date_of_birth: '1985-05-23', + }); + }); + + it('leaves empty fields out of core entirely', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'name only'; + (document.getElementById('f-full-name') as HTMLInputElement).value = 'Bob'; + // Other fields left blank. + + 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.full_name).toBe('Bob'); + expect(msg.item.core.email).toBeUndefined(); + expect(msg.item.core.phone).toBeUndefined(); + expect(msg.item.core.address).toBeUndefined(); + expect(msg.item.core.date_of_birth).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/components/types/identity.ts b/extension/src/popup/components/types/identity.ts new file mode 100644 index 0000000..52e211d --- /dev/null +++ b/extension/src/popup/components/types/identity.ts @@ -0,0 +1,199 @@ +/// Identity: full_name, address (multiline), phone, email, date_of_birth. +/// Detail view shows a "profile card" signature block + plain rows. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; +import type { Item, ItemId, ManifestEntry } from '../../../shared/types'; +import { + renderRow, renderSignatureBlock, wireFieldHandlers, +} from '../fields'; + +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +export function teardown(): void { + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } +} + +function initials(name: string | undefined): string { + if (!name) return '?'; + const parts = name.trim().split(/\s+/).slice(0, 2); + return parts.map((p) => p.charAt(0).toUpperCase()).join('') || '?'; +} + +function formatDate(iso: string | undefined): string { + if (!iso) return ''; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) return iso; + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); +} + +export async function renderDetail(app: HTMLElement, item: Item): Promise { + if (item.core.type !== 'identity') return; + const c = item.core; + const sigInner = ` +
+
${escapeHtml(initials(c.full_name))}
+
+
${escapeHtml(c.full_name ?? item.title)}
+ ${c.email ? `
${escapeHtml(c.email)}
` : ''} +
+
+ `; + + app.innerHTML = ` +
+
+ ${renderSignatureBlock({ accent: 'amber', children: sigInner })} +
+ ${c.phone ? renderRow({ label: 'phone', value: c.phone, copyable: true }) : ''} + ${c.email ? renderRow({ label: 'email', value: c.email, copyable: true }) : ''} + ${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''} + ${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''} +
+ + + +
+
+ `; + + 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 === 'identity') ? existing.core : null; + + app.innerHTML = ` +
+
${mode === 'add' ? 'new identity' : 'edit identity'}
+ ${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 saveIdentity(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 saveIdentity(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).value.trim(); + + const core = { + type: 'identity' as const, + full_name: get('f-full-name') || undefined, + email: get('f-email') || undefined, + phone: get('f-phone') || undefined, + address: get('f-address') || undefined, + date_of_birth: get('f-dob') || undefined, + }; + + const now = Math.floor(Date.now() / 1000); + const item: Item = { + id: existing?.id ?? '', + title, type: 'identity', + 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 }); + } +}