From e0847907561baf8e07d33a9ffde9d66948cc80ce Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 23 Apr 2026 22:42:48 -0400 Subject: [PATCH] feat(ext/popup): Key view + form (concealed monospace signature block) --- extension/src/popup/components/item-detail.ts | 4 +- extension/src/popup/components/item-form.ts | 4 +- .../types/__tests__/key.save.test.ts | 64 ++++++ extension/src/popup/components/types/key.ts | 200 ++++++++++++++++++ 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 extension/src/popup/components/types/__tests__/key.save.test.ts create mode 100644 extension/src/popup/components/types/key.ts diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index bdb513a..85b0f59 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -8,6 +8,7 @@ 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'; +import * as key from './types/key'; export async function renderItemDetail(app: HTMLElement): Promise { // Tear down any tickers/handlers from a previous detail render before @@ -17,6 +18,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { secureNote.teardown(); identity.teardown(); card.teardown(); + key.teardown(); const item = getState().selectedItem; if (!item) { navigate('list'); return; } @@ -26,7 +28,7 @@ export async function renderItemDetail(app: HTMLElement): Promise { case 'secure_note': return secureNote.renderDetail(app, item); case 'identity': return identity.renderDetail(app, item); case 'card': return card.renderDetail(app, item); - case 'key': + case 'key': return key.renderDetail(app, item); 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 4d0253f..a5d6e57 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -7,12 +7,14 @@ 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'; +import * as key from './types/key'; 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(); + key.teardown(); const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; const type: ItemType = existing?.type ?? state.newType ?? 'login'; @@ -22,7 +24,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { case 'secure_note': return secureNote.renderForm(app, mode, existing); case 'identity': return identity.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing); - case 'key': + case 'key': return key.renderForm(app, mode, existing); case 'totp': case 'document': return renderComingSoon(app, type); } diff --git a/extension/src/popup/components/types/__tests__/key.save.test.ts b/extension/src/popup/components/types/__tests__/key.save.test.ts new file mode 100644 index 0000000..18c9bd1 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/key.save.test.ts @@ -0,0 +1,64 @@ +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: 'key', + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../key'; +import { sendMessage } from '../../../popup'; + +describe('Key save shape', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } }); + }); + + it('requires key_material and emits all populated fields', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'github ssh'; + (document.getElementById('f-key-material') as HTMLTextAreaElement).value = '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...'; + (document.getElementById('f-label') as HTMLInputElement).value = 'work laptop'; + (document.getElementById('f-public-key') as HTMLTextAreaElement).value = 'ssh-ed25519 AAAA...'; + (document.getElementById('f-algorithm') as HTMLInputElement).value = 'ed25519'; + + 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('key'); + expect(msg.item.core).toEqual({ + type: 'key', + key_material: '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...', + label: 'work laptop', + public_key: 'ssh-ed25519 AAAA...', + algorithm: 'ed25519', + }); + }); + + it('rejects empty key_material', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + (document.getElementById('f-title') as HTMLInputElement).value = 'no key'; + 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'); + expect(addCall).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/components/types/key.ts b/extension/src/popup/components/types/key.ts new file mode 100644 index 0000000..e45d7ff --- /dev/null +++ b/extension/src/popup/components/types/key.ts @@ -0,0 +1,200 @@ +/// Key: key_material (required, concealed multiline) + label/algorithm/public_key. +/// Form's key_material textarea uses CSS text-security to mask characters +/// since + + +
+
+
+
+
+
+
+ + +
+ + `; + + // Show/hide toggle for the key_material textarea. + let revealed = false; + document.getElementById('key-show-btn')?.addEventListener('click', () => { + revealed = !revealed; + const ta = document.getElementById('f-key-material') as HTMLTextAreaElement; + (ta.style as CSSStyleDeclaration & { webkitTextSecurity?: string }).webkitTextSecurity = revealed ? 'none' : 'disc'; + (document.getElementById('key-show-btn') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show'; + }); + + document.getElementById('cancel-btn')?.addEventListener('click', () => { + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + }); + document.getElementById('save-btn')?.addEventListener('click', async () => { + await saveKey(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 saveKey(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 keyMaterial = (document.getElementById('f-key-material') as HTMLTextAreaElement).value; + if (!keyMaterial) { setState({ error: 'Key material is required' }); return; } + + const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim(); + + const core = { + type: 'key' as const, + key_material: keyMaterial, + label: get('f-label') || undefined, + public_key: get('f-public-key') || undefined, + algorithm: get('f-algorithm') || undefined, + }; + + const now = Math.floor(Date.now() / 1000); + const item: Item = { + id: existing?.id ?? '', + title, type: 'key', + 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 }); + } +}