feat(ext/popup): Identity view + form (profile-card signature block)

This commit is contained in:
adlee-was-taken
2026-04-23 22:29:04 -04:00
parent 99d689b9b0
commit 113b0b690a
4 changed files with 281 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ import type { Item } from '../../shared/types';
import { getState } from '../popup'; import { getState } from '../popup';
import * as login from './types/login'; import * as login from './types/login';
import * as secureNote from './types/secure-note'; import * as secureNote from './types/secure-note';
import * as identity from './types/identity';
export async function renderItemDetail(app: HTMLElement): Promise<void> { export async function renderItemDetail(app: HTMLElement): Promise<void> {
// Tear down any tickers/handlers from a previous detail render before // Tear down any tickers/handlers from a previous detail render before
@@ -13,6 +14,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
// call all of them since the dispatcher doesn't know which was active. // call all of them since the dispatcher doesn't know which was active.
login.teardown(); login.teardown();
secureNote.teardown(); secureNote.teardown();
identity.teardown();
const item = getState().selectedItem; const item = getState().selectedItem;
if (!item) { navigate('list'); return; } if (!item) { navigate('list'); return; }
@@ -20,7 +22,7 @@ export async function renderItemDetail(app: HTMLElement): Promise<void> {
switch (item.type) { switch (item.type) {
case 'login': return login.renderDetail(app, item); case 'login': return login.renderDetail(app, item);
case 'secure_note': return secureNote.renderDetail(app, item); case 'secure_note': return secureNote.renderDetail(app, item);
case 'identity': case 'identity': return identity.renderDetail(app, item);
case 'card': case 'card':
case 'key': case 'key':
case 'totp': case 'totp':

View File

@@ -5,10 +5,12 @@ import { navigate, getState } from '../popup';
import type { Item, ItemType } from '../../shared/types'; import type { Item, ItemType } from '../../shared/types';
import * as login from './types/login'; import * as login from './types/login';
import * as secureNote from './types/secure-note'; import * as secureNote from './types/secure-note';
import * as identity from './types/identity';
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
login.teardown(); // detail-view's ticker/listener don't leak into form login.teardown(); // detail-view's ticker/listener don't leak into form
secureNote.teardown(); secureNote.teardown();
identity.teardown();
const state = getState(); const state = getState();
const existing = mode === 'edit' ? state.selectedItem : null; const existing = mode === 'edit' ? state.selectedItem : null;
const type: ItemType = existing?.type ?? state.newType ?? 'login'; const type: ItemType = existing?.type ?? state.newType ?? 'login';
@@ -16,7 +18,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
switch (type) { switch (type) {
case 'login': return login.renderForm(app, mode, existing); case 'login': return login.renderForm(app, mode, existing);
case 'secure_note': return secureNote.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 'card':
case 'key': case 'key':
case 'totp': case 'totp':

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../identity';
import { sendMessage } from '../../../popup';
describe('Identity 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 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();
});
});

View File

@@ -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<void> {
if (item.core.type !== 'identity') return;
const c = item.core;
const sigInner = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:36px;height:36px;border-radius:50%;background:#d29922;color:#0d1117;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;">${escapeHtml(initials(c.full_name))}</div>
<div>
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(c.full_name ?? item.title)}</div>
${c.email ? `<div style="font-size:11px;color:#8b949e;">${escapeHtml(c.email)}</div>` : ''}
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'amber', children: sigInner })}
</div>
${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) }) : ''}
<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>
`;
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 = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</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="Aaron Lee · personal"></div>
<div class="form-group"><label class="label" for="f-full-name">full name</label>
<input id="f-full-name" type="text" value="${escapeHtml(c?.full_name ?? '')}" placeholder="Aaron Lee"></div>
<div class="form-group"><label class="label" for="f-email">email</label>
<input id="f-email" type="email" value="${escapeHtml(c?.email ?? '')}" placeholder="aaron@example.com"></div>
<div class="form-group"><label class="label" for="f-phone">phone</label>
<input id="f-phone" type="tel" value="${escapeHtml(c?.phone ?? '')}" placeholder="+1 555 0100"></div>
<div class="form-group"><label class="label" for="f-address">address</label>
<textarea id="f-address" rows="3" placeholder="street, city, postcode...">${escapeHtml(c?.address ?? '')}</textarea></div>
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></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 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<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).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 });
}
}