diff --git a/extension/src/popup/components/types/__tests__/sections-save.test.ts b/extension/src/popup/components/types/__tests__/sections-save.test.ts new file mode 100644 index 0000000..8578ea8 --- /dev/null +++ b/extension/src/popup/components/types/__tests__/sections-save.test.ts @@ -0,0 +1,58 @@ +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: 'login', + vaultSettings: null, generatorDefaults: null, + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../login'; +import { sendMessage } from '../../../popup'; + +describe('Login form packs sectionsDraft into Item.sections', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } }); + }); + + it('persists added sections and fields', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'Example'; + + (document.querySelector('.disclosure__toggle') as HTMLButtonElement).click(); + (document.querySelector('.add-section') as HTMLButtonElement).click(); + (document.querySelector('[data-add-field="text"]') as HTMLButtonElement).click(); + + const labelInput = document.querySelector('[data-field-label="0-0"]') as HTMLInputElement; + labelInput.value = 'recovery email'; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + const valueInput = document.querySelector('[data-field-value-input="0-0"]') as HTMLInputElement; + valueInput.value = 'backup@example.com'; + valueInput.dispatchEvent(new Event('input', { bubbles: true })); + + 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.sections).toHaveLength(1); + expect(msg.item.sections[0].fields).toHaveLength(1); + expect(msg.item.sections[0].fields[0].label).toBe('recovery email'); + expect(msg.item.sections[0].fields[0].value).toEqual({ kind: 'text', value: 'backup@example.com' }); + expect(msg.item.sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/extension/src/popup/components/types/card.ts b/extension/src/popup/components/types/card.ts index d855857..ce5e1fb 100644 --- a/extension/src/popup/components/types/card.ts +++ b/extension/src/popup/components/types/card.ts @@ -2,15 +2,17 @@ /// 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 type { Item, ItemId, ManifestEntry, CardKind, Section } from '../../../shared/types'; import { renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, + renderSectionsEditor, wireSectionsEditor, } from '../fields'; const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other']; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; +let sectionsExpanded = false; export function teardown(): void { if (activeKeyHandler) { @@ -21,6 +23,7 @@ export function teardown(): void { document.removeEventListener('keydown', activeFormEscHandler); activeFormEscHandler = null; } + sectionsExpanded = false; } function brandFromNumber(num: string): string { @@ -140,6 +143,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite const c = (existing?.core.type === 'card') ? existing.core : null; const currentYear = new Date().getFullYear(); + const sectionsDraft: Section[] = existing + ? JSON.parse(JSON.stringify(existing.sections)) as Section[] + : []; + const monthOptions = Array.from({ length: 12 }, (_, i) => { const m = String(i + 1).padStart(2, '0'); const sel = c?.expiry?.month === i + 1 ? 'selected' : ''; @@ -176,6 +183,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -183,12 +191,21 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
`; + const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); + }; + wireSectionsEditor(app, sectionsDraft, rerender); + 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); + await saveCard(mode, existing, sectionsDraft); }); const escHandler = (e: KeyboardEvent) => { @@ -203,7 +220,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); } -async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise { +async function saveCard(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); if (!title) { setState({ error: 'Title is required' }); return; } @@ -242,7 +259,7 @@ async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; +let sectionsExpanded = false; export function teardown(): void { if (activeKeyHandler) { @@ -19,6 +21,7 @@ export function teardown(): void { document.removeEventListener('keydown', activeFormEscHandler); activeFormEscHandler = null; } + sectionsExpanded = false; } function initials(name: string | undefined): string { @@ -115,6 +118,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite const title = existing?.title ?? ''; const c = (existing?.core.type === 'identity') ? existing.core : null; + const sectionsDraft: Section[] = existing + ? JSON.parse(JSON.stringify(existing.sections)) as Section[] + : []; + app.innerHTML = `
${mode === 'add' ? 'new identity' : 'edit identity'}
@@ -131,6 +138,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -138,12 +146,21 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
`; + const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); + }; + wireSectionsEditor(app, sectionsDraft, rerender); + 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); + await saveIdentity(mode, existing, sectionsDraft); }); const escHandler = (e: KeyboardEvent) => { @@ -158,7 +175,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); } -async function saveIdentity(mode: 'add' | 'edit', existing: Item | null): Promise { +async function saveIdentity(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); if (!title) { setState({ error: 'Title is required' }); return; } @@ -184,7 +201,7 @@ async function saveIdentity(mode: 'add' | 'edit', existing: Item | null): Promis created: existing?.created ?? now, modified: now, trashed_at: undefined, core, - sections: existing?.sections ?? [], + sections: sectionsDraft, attachments: existing?.attachments ?? [], field_history: existing?.field_history ?? {}, }; diff --git a/extension/src/popup/components/types/key.ts b/extension/src/popup/components/types/key.ts index 9de8030..51a3016 100644 --- a/extension/src/popup/components/types/key.ts +++ b/extension/src/popup/components/types/key.ts @@ -3,13 +3,15 @@ /// since + ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -128,6 +136,15 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
`; + const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); + }; + wireSectionsEditor(app, sectionsDraft, rerender); + // Show/hide toggle for the key_material textarea. let revealed = false; document.getElementById('key-show-btn')?.addEventListener('click', () => { @@ -142,7 +159,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite navigate(mode === 'edit' ? 'detail' : 'list'); }); document.getElementById('save-btn')?.addEventListener('click', async () => { - await saveKey(mode, existing); + await saveKey(mode, existing, sectionsDraft); }); const escHandler = (e: KeyboardEvent) => { @@ -157,7 +174,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); } -async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise { +async function saveKey(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); if (!title) { setState({ error: 'Title is required' }); return; } @@ -185,7 +202,7 @@ async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise let totpTickerId: ReturnType | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; +let sectionsExpanded = false; function stopTotpTicker(): void { if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } } @@ -217,6 +221,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite const group = existing?.group ?? ''; const notes = existing?.notes ?? ''; + const sectionsDraft: Section[] = existing + ? JSON.parse(JSON.stringify(existing.sections)) as Section[] + : []; + app.innerHTML = `
${mode === 'add' ? 'new login' : 'edit login'}
@@ -238,6 +246,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -245,6 +254,15 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
`; + const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); + }; + wireSectionsEditor(app, sectionsDraft, rerender); + document.getElementById('gen-btn')?.addEventListener('click', async () => { const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST }); if (resp.ok) { @@ -261,7 +279,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite }); document.getElementById('save-btn')?.addEventListener('click', async () => { - await saveLogin(mode, existing); + await saveLogin(mode, existing, sectionsDraft); }); const escHandler = (e: KeyboardEvent) => { @@ -289,7 +307,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e } } -async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise { +async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; @@ -339,7 +357,7 @@ async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise. import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; -import type { Item, ItemId, ManifestEntry } from '../../../shared/types'; +import type { Item, ItemId, ManifestEntry, Section } from '../../../shared/types'; import { renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, + renderSectionsEditor, wireSectionsEditor, } from '../fields'; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; +let sectionsExpanded = false; export function teardown(): void { if (activeKeyHandler) { @@ -19,6 +21,7 @@ export function teardown(): void { document.removeEventListener('keydown', activeFormEscHandler); activeFormEscHandler = null; } + sectionsExpanded = false; } export async function renderDetail(app: HTMLElement, item: Item): Promise { @@ -93,6 +96,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite const title = existing?.title ?? ''; const body = (existing?.core.type === 'secure_note') ? existing.core.body ?? '' : ''; + const sectionsDraft: Section[] = existing + ? JSON.parse(JSON.stringify(existing.sections)) as Section[] + : []; + app.innerHTML = `
${mode === 'add' ? 'new secure note' : 'edit secure note'}
@@ -101,6 +108,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -108,12 +116,21 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
`; + const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); + }; + wireSectionsEditor(app, sectionsDraft, rerender); + document.getElementById('cancel-btn')?.addEventListener('click', () => { setState({ error: null }); navigate(mode === 'edit' ? 'detail' : 'list'); }); document.getElementById('save-btn')?.addEventListener('click', async () => { - await saveSecureNote(mode, existing); + await saveSecureNote(mode, existing, sectionsDraft); }); const escHandler = (e: KeyboardEvent) => { @@ -128,7 +145,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); } -async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Promise { +async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); const body = (document.getElementById('f-body') as HTMLTextAreaElement).value; @@ -146,7 +163,7 @@ async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Prom modified: now, trashed_at: undefined, core: { type: 'secure_note', body }, - sections: existing?.sections ?? [], + sections: sectionsDraft, attachments: existing?.attachments ?? [], field_history: existing?.field_history ?? {}, }; diff --git a/extension/src/popup/components/types/totp.ts b/extension/src/popup/components/types/totp.ts index dad022a..befc8c5 100644 --- a/extension/src/popup/components/types/totp.ts +++ b/extension/src/popup/components/types/totp.ts @@ -3,10 +3,11 @@ /// (TOTP vs Steam Guard) and a single secret input. import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; -import type { Item, ItemId, ManifestEntry, TotpKind } from '../../../shared/types'; +import type { Item, ItemId, ManifestEntry, Section, TotpKind } from '../../../shared/types'; import { base32Decode, base32Encode } from '../../../shared/base32'; import { renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections, + renderSectionsEditor, wireSectionsEditor, } from '../fields'; // ---------------------------------------------------------------------- @@ -16,6 +17,7 @@ import { let totpTickerId: ReturnType | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null; +let sectionsExpanded = false; function stopTotpTicker(): void { if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } @@ -33,6 +35,7 @@ export function teardown(): void { document.removeEventListener('keydown', activeFormEscHandler); activeFormEscHandler = null; } + sectionsExpanded = false; } // ---------------------------------------------------------------------- @@ -194,6 +197,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite formKind = c?.config.kind === 'steam' ? 'steam' : 'totp'; const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : ''; + const sectionsDraft: Section[] = existing + ? JSON.parse(JSON.stringify(existing.sections)) as Section[] + : []; + const renderInner = (): string => `
${mode === 'add' ? 'new totp' : 'edit totp'}
@@ -213,6 +220,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -235,7 +243,20 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite (document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal; (document.getElementById('f-label') as HTMLInputElement).value = labelVal; wireKindToggle(); - wireFormButtons(mode, existing); + wireFormButtons(mode, existing, sectionsDraft); + wireSectionsEditor(app, sectionsDraft, sectionsRerender); + }; + + // Rerender only the sections editor in place (used by structural section + // mutations — add/remove). Reuses the form-wide reRender for simplicity + // since kind toggle already re-mounts the full inner DOM; here we just + // need to preserve sectionsExpanded and swap the disclosure block. + const sectionsRerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, sectionsRerender); }; const wireKindToggle = (): void => { @@ -250,7 +271,8 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite }; wireKindToggle(); - wireFormButtons(mode, existing); + wireFormButtons(mode, existing, sectionsDraft); + wireSectionsEditor(app, sectionsDraft, sectionsRerender); const escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -264,17 +286,17 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); } -function wireFormButtons(mode: 'add' | 'edit', existing: Item | null): void { +function wireFormButtons(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): void { document.getElementById('cancel-btn')?.addEventListener('click', () => { setState({ error: null }); navigate(mode === 'edit' ? 'detail' : 'list'); }); document.getElementById('save-btn')?.addEventListener('click', async () => { - await saveTotp(mode, existing); + await saveTotp(mode, existing, sectionsDraft); }); } -async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise { +async function saveTotp(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); if (!title) { setState({ error: 'Title is required' }); return; } @@ -317,7 +339,7 @@ async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise