feat(ext/popup): Identity view + form (profile-card signature block)
This commit is contained in:
@@ -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':
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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, '"').replace(/'/g, ''');
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
199
extension/src/popup/components/types/identity.ts
Normal file
199
extension/src/popup/components/types/identity.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user