diff --git a/extension/src/popup/components/__tests__/generator-popover.test.ts b/extension/src/popup/components/__tests__/generator-popover.test.ts new file mode 100644 index 0000000..07e4f98 --- /dev/null +++ b/extension/src/popup/components/__tests__/generator-popover.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../popup', async () => { + const sendMessage = vi.fn(); + return { sendMessage }; +}); + +import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover'; +import { sendMessage } from '../../popup'; +import type { GeneratorRequest } from '../../../shared/types'; + +const DEFAULT_REQ: GeneratorRequest = { + kind: 'random', + length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, +}; + +function setupAnchor(): HTMLElement { + document.body.innerHTML = ''; + return document.getElementById('anchor')!; +} + +describe('generator-popover', () => { + beforeEach(() => { + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } }); + }); + + it('opens a popover with Random kind by default', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + expect(document.querySelector('.generator-popover')).not.toBeNull(); + expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true); + }); + + it('sends generate_password on knob change (debounced)', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + const slider = document.querySelector('#gen-length') as HTMLInputElement; + slider.value = '32'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + const calls = vi.mocked(sendMessage).mock.calls.filter( + ([msg]) => (msg as { type: string }).type === 'generate_password', + ); + const latest = calls[calls.length - 1]![0] as { request: GeneratorRequest }; + expect(latest.request.kind).toBe('random'); + if (latest.request.kind === 'random') { + expect(latest.request.length).toBe(32); + } + }); + + it('BIP39 toggle swaps to generate_passphrase', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + (document.getElementById('gen-kind-bip39') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 200)); + const calls = vi.mocked(sendMessage).mock.calls; + expect(calls.some(([msg]) => (msg as { type: string }).type === 'generate_passphrase')).toBe(true); + }); + + it('use-this-value invokes onPicked with current preview and closes', async () => { + const anchor = setupAnchor(); + const onPicked = vi.fn(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked }); + await new Promise((r) => setTimeout(r, 200)); + (document.querySelector('#gen-use') as HTMLButtonElement).click(); + expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT'); + expect(document.querySelector('.generator-popover')).toBeNull(); + }); + + it('save-as-default sends update_vault_settings with the current request', async () => { + vi.mocked(sendMessage).mockImplementation(async (msg: any) => { + if (msg.type === 'generate_password') return { ok: true, data: { password: 'abc' } }; + if (msg.type === 'get_vault_settings') { + return { ok: true, data: { settings: { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: DEFAULT_REQ, + attachment_caps: {}, + autofill_origin_acks: {}, + } } }; + } + if (msg.type === 'update_vault_settings') return { ok: true }; + return { ok: false, error: 'unhandled' }; + }); + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + (document.querySelector('#gen-save-default') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + const updateCall = vi.mocked(sendMessage).mock.calls.find( + ([m]) => (m as any).type === 'update_vault_settings', + ); + expect(updateCall).toBeDefined(); + const msg = updateCall![0] as { settings: { generator_defaults: GeneratorRequest } }; + expect(msg.settings.generator_defaults.kind).toBe('random'); + }); + + it('disables use-button when no char class selected (Random)', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) { + const cb = document.getElementById(id) as HTMLInputElement; + cb.checked = false; + cb.dispatchEvent(new Event('change', { bubbles: true })); + } + const useBtn = document.querySelector('#gen-use') as HTMLButtonElement; + expect(useBtn.disabled).toBe(true); + }); + + it('closeGeneratorPopover removes the DOM + handlers', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + closeGeneratorPopover(); + expect(document.querySelector('.generator-popover')).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/__tests__/sections-editor.test.ts b/extension/src/popup/components/__tests__/sections-editor.test.ts new file mode 100644 index 0000000..86d3ac7 --- /dev/null +++ b/extension/src/popup/components/__tests__/sections-editor.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderSectionsEditor, generateFieldId, wireSectionsEditor } from '../fields'; +import type { Section } from '../../../shared/types'; + +describe('generateFieldId', () => { + it('returns 16 hex chars', () => { + const id = generateFieldId(); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + it('returns unique values on successive calls', () => { + const ids = new Set(Array.from({ length: 50 }, () => generateFieldId())); + expect(ids.size).toBe(50); + }); +}); + +describe('renderSectionsEditor', () => { + it('shows the disclosure toggle with the correct count', () => { + const sections: Section[] = [ + { name: 'a', fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false }, + { id: 'f1', label: 'l', kind: 'password', value: { kind: 'password', value: 'p' }, hidden_by_default: true }, + ] }, + { fields: [ + { id: 'f2', label: 'l', kind: 'concealed', value: { kind: 'concealed', value: 'c' }, hidden_by_default: true }, + ] }, + ]; + const html = renderSectionsEditor(sections, false); + expect(html).toContain('2 sections'); + expect(html).toContain('3 fields'); + expect(html).toContain('data-expanded="false"'); + }); + + it('shows singular "1 section / 1 field" when applicable', () => { + const sections: Section[] = [ + { name: 'only', fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false }, + ] }, + ]; + const html = renderSectionsEditor(sections, false); + expect(html).toContain('1 section'); + expect(html).toContain('1 field'); + expect(html).not.toContain('1 sections'); + expect(html).not.toContain('1 fields'); + }); + + it('renders expanded body when expanded=true', () => { + const html = renderSectionsEditor([], true); + expect(html).toContain('data-expanded="true"'); + expect(html).toContain('add section'); + }); +}); + +describe('wireSectionsEditor', () => { + it('toggle click flips data-expanded', () => { + document.body.innerHTML = renderSectionsEditor([], false); + const sections: Section[] = []; + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const toggle = document.querySelector('.disclosure__toggle') as HTMLButtonElement; + toggle.click(); + const disclosure = document.querySelector('.disclosure') as HTMLElement; + expect(disclosure.getAttribute('data-expanded')).toBe('true'); + }); + + it('add-section click appends an empty section', () => { + const sections: Section[] = []; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const addBtn = document.querySelector('.add-section') as HTMLButtonElement; + addBtn.click(); + expect(sections).toHaveLength(1); + expect(sections[0]).toEqual({ name: undefined, fields: [] }); + expect(rerender).toHaveBeenCalled(); + }); + + it('add-text-field click on a section pushes a text field', () => { + const sections: Section[] = [{ name: undefined, fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement; + addText.click(); + expect(sections[0].fields).toHaveLength(1); + expect(sections[0].fields[0].kind).toBe('text'); + expect(sections[0].fields[0].value.kind).toBe('text'); + expect(sections[0].fields[0].value.value).toBe(''); + expect(sections[0].fields[0].hidden_by_default).toBe(false); + expect(sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/); + }); + + it('add-password-field sets hidden_by_default=true', () => { + const sections: Section[] = [{ name: undefined, fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-add-field="password"][data-section-idx="0"]') as HTMLButtonElement).click(); + expect(sections[0].fields[0].hidden_by_default).toBe(true); + expect(sections[0].fields[0].kind).toBe('password'); + }); + + it('remove-field button splices field', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'a', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + { id: 'f1', label: 'b', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const deleteBtn = document.querySelector('[data-delete-field="f0"]') as HTMLButtonElement; + deleteBtn.click(); + expect(sections[0].fields).toHaveLength(1); + expect(sections[0].fields[0].id).toBe('f1'); + }); + + it('remove-section button splices section (after confirm)', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const sections: Section[] = [ + { name: 'to-remove', fields: [] }, + { name: 'keep', fields: [] }, + ]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click(); + expect(sections).toHaveLength(1); + expect(sections[0].name).toBe('keep'); + confirmSpy.mockRestore(); + }); + + it('remove-section cancelled confirm leaves section intact', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const sections: Section[] = [{ name: 'stays', fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click(); + expect(sections).toHaveLength(1); + confirmSpy.mockRestore(); + }); + + it('label input change mutates section field label in place (no rerender)', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'old', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const labelInput = document.querySelector('[data-field-label="f0"]') as HTMLInputElement; + labelInput.value = 'new'; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(sections[0].fields[0].label).toBe('new'); + expect(rerender).not.toHaveBeenCalled(); + }); + + it('value input change mutates section field value in place', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'old' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const valueInput = document.querySelector('[data-field-value-input="f0"]') as HTMLInputElement; + valueInput.value = 'new'; + valueInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(sections[0].fields[0].value).toEqual({ kind: 'text', value: 'new' }); + }); +}); + +describe('wireSectionsEditor preserves unsupported-kind fields on save', () => { + it('renders preserved note when section contains unsupported-kind fields', () => { + const sections: Section[] = [{ + name: 'mixed', + fields: [ + { id: 'f0000001', label: 'note', kind: 'text', + value: { kind: 'text', value: 'ok' }, hidden_by_default: false }, + { id: 'f0000002', label: 'when', kind: 'date' as any, + value: { kind: 'date', value: '2026-01-01' } as any, hidden_by_default: false }, + ], + }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + expect(document.body.innerHTML).toContain('1 field of unsupported kind'); + expect(document.body.innerHTML).not.toContain('f0000002'); + }); + + it('add-text then save does not destroy unsupported-kind fields', () => { + const sections: Section[] = [{ + name: 'mixed', + fields: [ + { id: 'f0000002', label: 'when', kind: 'date' as any, + value: { kind: 'date', value: '2026-01-01' } as any, hidden_by_default: false }, + ], + }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement; + addText.click(); + expect(sections[0].fields).toHaveLength(2); + // Unsupported-kind field preserved untouched. + const dateField = sections[0].fields.find((f) => f.id === 'f0000002'); + expect(dateField).toBeDefined(); + expect(dateField!.value).toEqual({ kind: 'date', value: '2026-01-01' }); + }); +}); diff --git a/extension/src/popup/components/__tests__/sections-render.test.ts b/extension/src/popup/components/__tests__/sections-render.test.ts new file mode 100644 index 0000000..435d26f --- /dev/null +++ b/extension/src/popup/components/__tests__/sections-render.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { renderSections } from '../fields'; +import type { Item } from '../../../shared/types'; + +function itemWithSections(sections: Item['sections']): Item { + return { + id: 'aaaaaaaaaaaaaaaa', + title: 'test', + type: 'login', + tags: [], favorite: false, + created: 0, modified: 0, + core: { type: 'login' }, + sections, + attachments: [], + field_history: {}, + }; +} + +describe('renderSections', () => { + it('returns empty string when item has no sections', () => { + const html = renderSections(itemWithSections([]), 'login'); + expect(html).toBe(''); + }); + + it('skips sections with zero fields', () => { + const html = renderSections(itemWithSections([ + { name: 'empty', fields: [] }, + ]), 'login'); + expect(html).not.toContain('empty'); + }); + + it('renders a named section header + field rows', () => { + const html = renderSections(itemWithSections([ + { + name: 'recovery codes', + fields: [ + { id: 'f0000001', label: 'code 1', kind: 'text', + value: { kind: 'text', value: 'abc-123' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('recovery codes'); + expect(html).toContain('code 1'); + expect(html).toContain('abc-123'); + }); + + it('renders concealed password fields with unique ids', () => { + const html = renderSections(itemWithSections([ + { + name: 'backup', + fields: [ + { id: 'f0000002', label: 'pin', kind: 'password', + value: { kind: 'password', value: 'hunter2' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('data-revealed="false"'); + expect(html).not.toMatch(/>hunter2 { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000003', label: 'extra', kind: 'text', + value: { kind: 'text', value: 'note' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('section-separator'); + expect(html).not.toContain('section-header'); + }); + + it('silently skips unsupported field kinds', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000004', label: 'link', kind: 'url' as any, + value: { kind: 'url', value: 'https://example.com' } as any, + hidden_by_default: false }, + { id: 'f0000005', label: 'note', kind: 'text', + value: { kind: 'text', value: 'kept' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).not.toContain('https://example.com'); + expect(html).toContain('kept'); + }); + + it('renders concealed fields for the concealed kind too', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000006', label: 'secret', kind: 'concealed', + value: { kind: 'concealed', value: 'shhh' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('secret'); + expect(html).not.toMatch(/>shhh { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'settings-vault', + entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: null, + vaultSettings: { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: { + kind: 'random', length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, + }, + attachment_caps: {}, + autofill_origin_acks: { 'github.com': 1000000000, 'example.com': 999000000 }, + }, + generatorDefaults: null, + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +vi.mock('../generator-popover', () => ({ + openGeneratorPopover: vi.fn(), + closeGeneratorPopover: vi.fn(), +})); + +import { renderVaultSettings } from '../settings-vault'; +import { sendMessage } from '../../popup'; + +describe('settings-vault', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true }); + }); + + it('renders with seeded vault-settings values', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + expect(app.textContent).toContain('vault settings'); + expect(app.textContent).toContain('github.com'); + expect(app.textContent).toContain('example.com'); + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + expect(trashSel.value).toBe('days:30'); + const histSel = document.getElementById('history-retention') as HTMLSelectElement; + expect(histSel.value).toBe('forever'); + }); + + it('renders origin acks sorted by recency (descending)', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const rows = Array.from(document.querySelectorAll('.ack-row__host')).map((e) => e.textContent); + expect(rows).toEqual(['github.com', 'example.com']); + }); + + it('save button disabled until a change is made', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement; + expect(saveBtn.disabled).toBe(true); + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + trashSel.value = 'forever'; + trashSel.dispatchEvent(new Event('change', { bubbles: true })); + expect(saveBtn.disabled).toBe(false); + }); + + it('revoke button removes origin from pending and enables save', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + (document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click(); + expect(document.querySelector('[data-revoke="github.com"]')).toBeNull(); + expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false); + }); + + it('save button triggers update_vault_settings with pending', async () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + (document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click(); + (document.getElementById('save-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 10)); + const call = vi.mocked(sendMessage).mock.calls.find( + ([m]) => (m as any).type === 'update_vault_settings', + ); + expect(call).toBeDefined(); + const payload = call![0] as { settings: any }; + expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com'); + expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com'); + }); +}); diff --git a/extension/src/popup/components/fields.ts b/extension/src/popup/components/fields.ts index 1e0694a..a7be540 100644 --- a/extension/src/popup/components/fields.ts +++ b/extension/src/popup/components/fields.ts @@ -6,6 +6,7 @@ /// copy click handlers on any rendered rows. import { escapeHtml } from '../popup'; +import type { Item, Section, Field, FieldValue } from '../../shared/types'; export interface RowOpts { label: string; @@ -117,3 +118,237 @@ export function wireFieldHandlers(scope: HTMLElement): void { }); }); } + +/// Render an Item's sections as read-only field rows. Each section with +/// ≥1 field emits a header (if named) or thin separator (if anonymous) +/// plus field rows via renderRow / renderConcealedRow. Sections with +/// 0 fields are skipped. Fields with unsupported kinds are silently +/// skipped (β₂ supports text, password, concealed only). +/// +/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`) +/// so multiple typed-item detail views rendered in sequence don't +/// collide on wireFieldHandlers lookups. +export function renderSections(item: Item, idPrefix: string): string { + let out = ''; + item.sections.forEach((section, sIdx) => { + const visibleFields = section.fields.filter( + (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', + ); + if (visibleFields.length === 0) return; + + if (section.name) { + out += `
${escapeHtml(section.name)}
`; + } else { + out += `
`; + } + + visibleFields.forEach((field, fIdx) => { + if (field.value.kind === 'text') { + out += renderRow({ label: field.label, value: field.value.value, copyable: true }); + } else if (field.value.kind === 'password' || field.value.kind === 'concealed') { + out += renderConcealedRow({ + id: `${idPrefix}-s${sIdx}-f${fIdx}`, + label: field.label, + value: field.value.value, + }); + } + }); + }); + return out; +} + +/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes. +export function generateFieldId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function makeField(kind: 'text' | 'password' | 'concealed'): Field { + const value: FieldValue = { kind, value: '' }; + return { + id: generateFieldId(), + label: 'new field', + kind, + value, + hidden_by_default: kind !== 'text', + }; +} + +/// Render the collapsible custom-sections editor. Returns HTML for the +/// disclosure toggle + body. The expanded-state is owned externally +/// (via a module-scope flag in the caller); this helper reads it as +/// the `expanded` parameter. +export function renderSectionsEditor(sections: Section[], expanded: boolean): string { + const sectionCount = sections.length; + const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0); + const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`; + const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`; + const summary = sectionCount === 0 && fieldCount === 0 + ? 'no custom fields' + : `${sectionLabel}, ${fieldLabel}`; + + const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join(''); + + return ` +
+ +
+ ${body} + +
+
+ `; +} + +function renderSectionBlock(section: Section, sIdx: number): string { + const nameDisplay = section.name + ? `${escapeHtml(section.name)}` + : `(anonymous)`; + + // Only render supported kinds. Other-kind fields stay in sectionsDraft + // untouched so they survive save intact. + const editable = section.fields.filter( + (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', + ); + const fieldsHtml = editable.map((f) => renderEditorField(f, sIdx, 0)).join(''); + + const preservedCount = section.fields.length - editable.length; + const preservedNote = preservedCount > 0 + ? `
${preservedCount} field${preservedCount === 1 ? '' : 's'} of unsupported kind (edit via CLI)
` + : ''; + + return ` +
+
+ ${nameDisplay} + + + + +
+ ${fieldsHtml} + ${preservedNote} +
+ + + +
+
+ `; +} + +function renderEditorField(field: Field, sIdx: number, _fIdx: number): string { + const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') + ? field.value.value + : ''; + const inputType = field.value.kind === 'text' ? 'text' : 'password'; + return ` +
+ + + +
+ `; +} + +function findField( + sectionsDraft: Section[], + fieldId: string, +): { section: Section; fieldIdx: number } | null { + for (const section of sectionsDraft) { + const idx = section.fields.findIndex((f) => f.id === fieldId); + if (idx >= 0) return { section, fieldIdx: idx }; + } + return null; +} + +/// Wire click + input handlers on a rendered sections-editor. Mutations +/// happen in place on `sectionsDraft`. `rerender` is called after any +/// structural change (add/remove) to regenerate the disclosure body; +/// label/value edits do NOT trigger rerender (would steal focus). +export function wireSectionsEditor( + scope: HTMLElement, + sectionsDraft: Section[], + rerender: () => void, +): void { + const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null; + toggle?.addEventListener('click', () => { + const disclosure = scope.querySelector('.disclosure') as HTMLElement | null; + if (!disclosure) return; + const expanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true'); + }); + + scope.querySelector('.add-section')?.addEventListener('click', () => { + sectionsDraft.push({ name: undefined, fields: [] }); + rerender(); + }); + + scope.querySelectorAll('[data-rename-section]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.renameSection); + const current = sectionsDraft[sIdx]?.name ?? ''; + const name = window.prompt('Section name (empty for none):', current); + if (name === null) return; + const trimmed = name.trim(); + sectionsDraft[sIdx].name = trimmed || undefined; + rerender(); + }); + }); + + scope.querySelectorAll('[data-remove-section]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.removeSection); + const name = sectionsDraft[sIdx]?.name ?? '(anonymous)'; + if (!window.confirm(`Remove section "${name}" and all its fields?`)) return; + sectionsDraft.splice(sIdx, 1); + rerender(); + }); + }); + + scope.querySelectorAll('[data-add-field]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.sectionIdx); + const kind = btn.dataset.addField as 'text' | 'password' | 'concealed'; + sectionsDraft[sIdx].fields.push(makeField(kind)); + rerender(); + }); + }); + + scope.querySelectorAll('[data-delete-field]').forEach((btn) => { + btn.addEventListener('click', () => { + const fieldId = btn.dataset.deleteField ?? ''; + const found = findField(sectionsDraft, fieldId); + if (!found) return; + found.section.fields = found.section.fields.filter((f) => f.id !== fieldId); + rerender(); + }); + }); + + scope.querySelectorAll('[data-field-label]').forEach((input) => { + input.addEventListener('input', () => { + const fieldId = input.dataset.fieldLabel ?? ''; + const found = findField(sectionsDraft, fieldId); + if (found) { + found.section.fields[found.fieldIdx].label = input.value; + } + }); + }); + + scope.querySelectorAll('[data-field-value-input]').forEach((input) => { + input.addEventListener('input', () => { + const fieldId = input.dataset.fieldValueInput ?? ''; + const found = findField(sectionsDraft, fieldId); + if (!found) return; + const field = found.section.fields[found.fieldIdx]; + // Only mutate supported kinds. Unsupported kinds are never rendered + // as editable (filtered by renderSectionBlock), so this path shouldn't + // fire for them — but guard defensively. + if (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') { + const kind = field.value.kind; + field.value = { kind, value: input.value }; + } + }); + }); +} diff --git a/extension/src/popup/components/generator-popover.ts b/extension/src/popup/components/generator-popover.ts new file mode 100644 index 0000000..1669c88 --- /dev/null +++ b/extension/src/popup/components/generator-popover.ts @@ -0,0 +1,350 @@ +/// Inline generator popover — anchored to a "gen" button, renders a +/// live preview that updates as knobs change (150ms debounce). Single +/// underlying GeneratorRequest; kind toggle swaps between Random + +/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel. + +import { sendMessage } from '../popup'; +import type { GeneratorRequest, VaultSettings } from '../../shared/types'; + +interface UiKnobs { + kind: 'random' | 'bip39'; + // Random + length: number; + lower: boolean; + upper: boolean; + digits: boolean; + symbols: boolean; + symbolCharset: 'safe_only' | 'extended' | 'custom'; + customSymbols: string; + // BIP39 + wordCount: number; + separator: string; + capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed'; +} + +function knobsFromRequest(req: GeneratorRequest): UiKnobs { + const defaults: UiKnobs = { + kind: 'random', + length: 20, lower: true, upper: true, digits: true, symbols: true, + symbolCharset: 'safe_only', customSymbols: '', + wordCount: 5, separator: ' ', capitalization: 'lower', + }; + if (req.kind === 'random') { + return { + ...defaults, + kind: 'random', + length: req.length, + lower: req.classes.lower, + upper: req.classes.upper, + digits: req.classes.digits, + symbols: req.classes.symbols, + symbolCharset: req.symbol_charset.kind, + customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '', + }; + } + return { + ...defaults, + kind: 'bip39', + wordCount: req.word_count, + separator: req.separator, + capitalization: req.capitalization, + }; +} + +function requestFromKnobs(knobs: UiKnobs): GeneratorRequest { + if (knobs.kind === 'random') { + return { + kind: 'random', + length: knobs.length, + classes: { + lower: knobs.lower, upper: knobs.upper, + digits: knobs.digits, symbols: knobs.symbols, + }, + symbol_charset: + knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } : + knobs.symbolCharset === 'extended' ? { kind: 'extended' } : + { kind: 'custom', value: knobs.customSymbols }, + }; + } + return { + kind: 'bip39', + word_count: knobs.wordCount, + separator: knobs.separator, + capitalization: knobs.capitalization, + }; +} + +let activePopover: { + host: HTMLElement; + cleanup: () => void; +} | null = null; +let debounceTimer: ReturnType | null = null; + +export interface OpenPopoverOpts { + anchor: HTMLElement; + initial: GeneratorRequest; + onPicked: (value: string) => void; +} + +export function openGeneratorPopover(opts: OpenPopoverOpts): void { + closeGeneratorPopover(); + + const knobs = knobsFromRequest(opts.initial); + let currentPreview = ''; + + const host = document.createElement('div'); + host.className = 'generator-popover'; + document.body.appendChild(host); + + // Position below anchor + const rect = opts.anchor.getBoundingClientRect(); + host.style.top = `${rect.bottom + 6}px`; + host.style.left = `${rect.left}px`; + + const render = (): void => { + host.innerHTML = buildInnerHtml(knobs); + wireInner(); + refreshPreview(); + }; + + const refreshPreview = (): void => { + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + debounceTimer = null; + const request = requestFromKnobs(knobs); + const msg = knobs.kind === 'random' + ? { type: 'generate_password' as const, request } + : { type: 'generate_passphrase' as const, request }; + const resp = await sendMessage(msg); + if (resp.ok) { + const d = resp.data as { password?: string; passphrase?: string }; + currentPreview = d.password ?? d.passphrase ?? ''; + const el = host.querySelector('.gen-preview__value'); + if (el) el.textContent = currentPreview; + updateValidation(); + } + }, 150); + }; + + const updateValidation = (): void => { + const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null; + if (!useBtn) return; + const noClass = knobs.kind === 'random' + && !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols); + useBtn.disabled = noClass; + const note = host.querySelector('.gen-validation'); + if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none'; + }; + + const wireInner = (): void => { + host.querySelector('#gen-kind-random')?.addEventListener('click', () => { + knobs.kind = 'random'; render(); + }); + host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => { + knobs.kind = 'bip39'; render(); + }); + + host.querySelector('#gen-length')?.addEventListener('input', (e) => { + knobs.length = Number((e.target as HTMLInputElement).value); + const out = host.querySelector('#gen-length-val'); + if (out) out.textContent = String(knobs.length); + refreshPreview(); + }); + + for (const { id, key } of [ + { id: 'gen-lower', key: 'lower' as const }, + { id: 'gen-upper', key: 'upper' as const }, + { id: 'gen-digits', key: 'digits' as const }, + { id: 'gen-symbols', key: 'symbols' as const }, + ]) { + host.querySelector(`#${id}`)?.addEventListener('change', (e) => { + knobs[key] = (e.target as HTMLInputElement).checked; + updateValidation(); + refreshPreview(); + }); + } + + host.querySelectorAll('[data-symbol-charset]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset']; + render(); + }); + }); + + host.querySelector('#gen-word-count')?.addEventListener('input', (e) => { + knobs.wordCount = Number((e.target as HTMLInputElement).value); + const out = host.querySelector('#gen-word-count-val'); + if (out) out.textContent = String(knobs.wordCount); + refreshPreview(); + }); + + host.querySelectorAll('[data-separator]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.separator = btn.dataset.separator ?? ' '; + render(); + }); + }); + + host.querySelectorAll('[data-capitalization]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization']; + render(); + }); + }); + + host.querySelector('.gen-preview__regen')?.addEventListener('click', () => { + refreshPreview(); + }); + + host.querySelector('#gen-use')?.addEventListener('click', () => { + opts.onPicked(currentPreview); + closeGeneratorPopover(); + }); + + host.querySelector('#gen-save-default')?.addEventListener('click', async () => { + const getResp = await sendMessage({ type: 'get_vault_settings' }); + if (!getResp.ok) return; + const vs = (getResp.data as { settings: VaultSettings }).settings; + const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) }; + await sendMessage({ type: 'update_vault_settings', settings: updated }); + const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null; + if (btn) { + const original = btn.textContent; + btn.textContent = 'saved'; + setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 1500); + } + }); + + host.querySelector('#gen-reset')?.addEventListener('click', async () => { + const getResp = await sendMessage({ type: 'get_vault_settings' }); + if (!getResp.ok) return; + const vs = (getResp.data as { settings: VaultSettings }).settings; + Object.assign(knobs, knobsFromRequest(vs.generator_defaults)); + render(); + }); + + host.querySelector('#gen-cancel')?.addEventListener('click', () => closeGeneratorPopover()); + host.querySelector('#gen-close')?.addEventListener('click', () => closeGeneratorPopover()); + }; + + const onOutsideClick = (e: MouseEvent) => { + if (!host.contains(e.target as Node) && e.target !== opts.anchor) { + closeGeneratorPopover(); + } + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeGeneratorPopover(); + }; + + const cleanup = (): void => { + document.removeEventListener('click', onOutsideClick, true); + document.removeEventListener('keydown', onEsc); + host.remove(); + }; + + activePopover = { host, cleanup }; + + setTimeout(() => { + document.addEventListener('click', onOutsideClick, true); + document.addEventListener('keydown', onEsc); + }, 0); + + render(); +} + +export function closeGeneratorPopover(): void { + if (activePopover === null) return; + if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } + activePopover.cleanup(); + activePopover = null; +} + +// --- HTML builders --- + +function buildInnerHtml(knobs: UiKnobs): string { + return ` +
+ generate + +
+
+ kind +
+ + +
+
+ ${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)} +
+ + +
+ ${knobs.kind === 'random' + ? `` + : ''} +
+ + + + +
+ `; +} + +function buildRandomKnobs(k: UiKnobs): string { + return ` +
+ length + + ${k.length} +
+
+ + + + +
+
+ symbols +
+ + +
+
+ `; +} + +function buildBip39Knobs(k: UiKnobs): string { + const sepChip = (label: string, sep: string) => ` + + `; + const capChip = (label: string, val: string) => ` + + `; + return ` +
+ words + + ${k.wordCount} +
+
+ separator +
+ ${sepChip('space', ' ')} + ${sepChip('-', '-')} + ${sepChip('_', '_')} + ${sepChip('.', '.')} + ${sepChip(':', ':')} +
+
+
+ case +
+ ${capChip('lower', 'lower')} + ${capChip('upper', 'upper')} + ${capChip('first', 'first_of_each')} + ${capChip('title', 'title')} +
+
+ `; +} diff --git a/extension/src/popup/components/item-list.ts b/extension/src/popup/components/item-list.ts index f532401..9b7c885 100644 --- a/extension/src/popup/components/item-list.ts +++ b/extension/src/popup/components/item-list.ts @@ -93,7 +93,10 @@ export function renderItemList(app: HTMLElement): void { navigate('locked'); }); - document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings')); + document.getElementById('settings-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + showSettingsPicker(e.currentTarget as HTMLElement); + }); // Item row clicks. const rows = app.querySelectorAll('.entry-row'); @@ -307,3 +310,80 @@ function showNewTypePicker(anchor: HTMLElement): void { document.addEventListener('keydown', closeOnEsc); }, 0); } + +// ---------------------------------------------------------------------- +// Settings picker popover (device vs vault) +// ---------------------------------------------------------------------- + +const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [ + { view: 'settings', icon: '🖥', label: 'device settings' }, + { view: 'settings-vault', icon: '🔐', label: 'vault settings' }, +]; + +function showSettingsPicker(anchor: HTMLElement): void { + document.querySelectorAll('.settings-picker').forEach((el) => el.remove()); + + const picker = document.createElement('div'); + picker.className = 'settings-picker'; + Object.assign(picker.style, { + position: 'absolute', + background: '#161b22', + border: '1px solid #30363d', + borderRadius: '6px', + boxShadow: '0 4px 12px rgba(0,0,0,0.4)', + padding: '4px', + minWidth: '170px', + zIndex: '999999', + fontSize: '12px', + }); + + const rect = anchor.getBoundingClientRect(); + picker.style.top = `${rect.bottom + 4}px`; + picker.style.left = `${rect.left}px`; + + for (const opt of SETTINGS_OPTIONS) { + const row = document.createElement('div'); + Object.assign(row.style, { + padding: '6px 10px', cursor: 'pointer', color: '#c9d1d9', + borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '8px', + }); + const iconSpan = document.createElement('span'); + iconSpan.textContent = opt.icon; + Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', textAlign: 'center' }); + const labelSpan = document.createElement('span'); + labelSpan.textContent = opt.label; + row.appendChild(iconSpan); + row.appendChild(labelSpan); + row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); + row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + picker.remove(); + document.removeEventListener('click', closeOnOutside); + document.removeEventListener('keydown', closeOnEsc); + navigate(opt.view); + }); + picker.appendChild(row); + } + + document.body.appendChild(picker); + + const closeOnOutside = (ev: MouseEvent) => { + if (!picker.contains(ev.target as Node)) { + picker.remove(); + document.removeEventListener('click', closeOnOutside); + document.removeEventListener('keydown', closeOnEsc); + } + }; + const closeOnEsc = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + picker.remove(); + document.removeEventListener('click', closeOnOutside); + document.removeEventListener('keydown', closeOnEsc); + } + }; + setTimeout(() => { + document.addEventListener('click', closeOnOutside); + document.addEventListener('keydown', closeOnEsc); + }, 0); +} diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts new file mode 100644 index 0000000..1e744e6 --- /dev/null +++ b/extension/src/popup/components/settings-vault.ts @@ -0,0 +1,236 @@ +/// Vault-level settings screen. Covers retention (trash + field history), +/// generator defaults (preview + "configure" → opens popover), and +/// autofill origin-ack revocation. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { + VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, +} from '../../shared/types'; +import { openGeneratorPopover } from './generator-popover'; + +let pendingSettings: VaultSettings | null = null; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +export function teardown(): void { + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + pendingSettings = null; +} + +// --- Retention helpers --- + +function trashRetentionToValue(r: TrashRetention): string { + if (r.kind === 'forever') return 'forever'; + return `days:${r.value}`; +} + +function valueToTrashRetention(v: string): TrashRetention { + if (v === 'forever') return { kind: 'forever' }; + const m = /^days:(\d+)$/.exec(v); + if (m) return { kind: 'days', value: Number(m[1]) }; + return { kind: 'forever' }; +} + +function historyRetentionToValue(r: HistoryRetention): string { + if (r.kind === 'forever') return 'forever'; + if (r.kind === 'last_n') return `last_n:${r.value}`; + return `days:${r.value}`; +} + +function valueToHistoryRetention(v: string): HistoryRetention { + if (v === 'forever') return { kind: 'forever' }; + const mLast = /^last_n:(\d+)$/.exec(v); + if (mLast) return { kind: 'last_n', value: Number(mLast[1]) }; + const mDays = /^days:(\d+)$/.exec(v); + if (mDays) return { kind: 'days', value: Number(mDays[1]) }; + return { kind: 'forever' }; +} + +// --- Generator summary --- + +function generatorSummary(req: GeneratorRequest): string { + if (req.kind === 'random') { + const classes: string[] = []; + if (req.classes.lower) classes.push('lower'); + if (req.classes.upper) classes.push('upper'); + if (req.classes.digits) classes.push('digits'); + if (req.classes.symbols) classes.push('symbols'); + const sc = req.symbol_charset.kind; + return `Random, ${req.length} chars, ${classes.join('+') || 'no classes'}, ${sc} symbols`; + } + return `BIP39, ${req.word_count} words, "${req.separator}" separator, ${req.capitalization}`; +} + +// --- Time formatting --- + +function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// --- Render --- + +export function renderVaultSettings(app: HTMLElement): void { + const state = getState(); + const base = state.vaultSettings; + if (!base) { + app.innerHTML = `

Vault settings not loaded yet.

`; + return; + } + pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings; + + function rerender(): void { + if (!pendingSettings) return; + const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) + .sort(([, a], [, b]) => b - a); + + app.innerHTML = ` +
+
+ +

vault settings

+
+ +
+
retention
+
+ trash + +
+
+ field history + +
+
+ +
+
generator
+

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

+ +
+ +
+
autofill origins
+ ${acksEntries.length === 0 + ? `

No origins acknowledged yet.

` + : acksEntries.map(([host, ts]) => ` +
+ ${escapeHtml(host)} + ${escapeHtml(relativeTime(ts))} + +
+ `).join('')} +
+ + +
+ `; + + // Set current select values + (document.getElementById('trash-retention') as HTMLSelectElement).value = + trashRetentionToValue(pendingSettings.trash_retention); + (document.getElementById('history-retention') as HTMLSelectElement).value = + historyRetentionToValue(pendingSettings.field_history_retention); + + wireHandlers(); + updateSaveEnabled(); + } + + function updateSaveEnabled(): void { + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null; + if (!saveBtn || !pendingSettings || !base) return; + const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base); + saveBtn.disabled = !changed; + } + + function wireHandlers(): void { + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list')); + + document.getElementById('trash-retention')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); + updateSaveEnabled(); + }); + + document.getElementById('history-retention')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); + updateSaveEnabled(); + }); + + document.querySelectorAll('[data-revoke]').forEach((btn) => { + btn.addEventListener('click', () => { + if (!pendingSettings) return; + const host = btn.dataset.revoke ?? ''; + delete pendingSettings.autofill_origin_acks[host]; + rerender(); + }); + }); + + document.getElementById('configure-gen')?.addEventListener('click', (e) => { + if (!pendingSettings) return; + const anchor = e.currentTarget as HTMLElement; + openGeneratorPopover({ + anchor, + initial: pendingSettings.generator_defaults, + onPicked: () => {/* no-op — user is here to save as default, not pick */}, + }); + }); + + document.getElementById('save-btn')?.addEventListener('click', async () => { + if (!pendingSettings) return; + const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); + if (resp.ok) { + // Refresh cached state and navigate back. + const refreshed = await sendMessage({ type: 'get_vault_settings' }); + if (refreshed.ok && refreshed.data) { + const vs = (refreshed.data as { settings: VaultSettings }).settings; + if (vs) { + setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); + } + } + navigate('list'); + } else { + setState({ error: resp.error }); + } + }); + } + + rerender(); + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (activeKeyHandler) document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + navigate('list'); + } + }; + activeKeyHandler = handler; + document.addEventListener('keydown', handler); +} 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..0c0483a --- /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]') as HTMLInputElement; + labelInput.value = 'recovery email'; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + const valueInput = document.querySelector('[data-field-value-input]') 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 400fc92..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, + 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 { @@ -79,6 +82,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''} ${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''} + ${renderSections(item, 'card')}
@@ -139,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' : ''; @@ -175,6 +183,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -182,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) => { @@ -202,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; } @@ -241,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 { @@ -57,6 +60,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${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) }) : ''} + ${renderSections(item, 'identity')}
@@ -114,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'}
@@ -130,6 +138,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -137,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) => { @@ -157,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; } @@ -183,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 8bdf334..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)}
@@ -127,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', () => { @@ -141,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) => { @@ -156,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; } @@ -184,7 +202,7 @@ async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise ` : ''} ${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''} + ${renderSections(item, 'login')}
@@ -179,6 +186,7 @@ export async function renderDetail(app: HTMLElement, item: Item): 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; } } @@ -215,6 +223,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'}
@@ -236,6 +248,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -243,14 +256,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
`; - document.getElementById('gen-btn')?.addEventListener('click', async () => { - const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST }); - if (resp.ok) { - const data = resp.data as { password: string }; - const pw = document.getElementById('f-password') as HTMLInputElement; - pw.value = data.password; - pw.type = 'text'; - } else setState({ error: resp.error }); + 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', (e) => { + const anchor = e.currentTarget as HTMLElement; + const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST; + openGeneratorPopover({ + anchor, + initial, + onPicked: (value) => { + const pw = document.getElementById('f-password') as HTMLInputElement | null; + if (pw) { pw.value = value; pw.type = 'text'; } + }, + }); }); document.getElementById('cancel-btn')?.addEventListener('click', () => { @@ -259,7 +284,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) => { @@ -287,7 +312,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; @@ -337,7 +362,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, + 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 { @@ -35,6 +38,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${renderSignatureBlock({ accent: 'green', children: sigInner })}
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })} + ${renderSections(item, 'secure-note')}
@@ -92,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'}
@@ -100,6 +108,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -107,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) => { @@ -127,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; @@ -145,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 f897e56..a925cb2 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, + 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; } // ---------------------------------------------------------------------- @@ -83,6 +86,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${c.label ? renderRow({ label: 'label', value: c.label }) : ''} ${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })} ${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })} + ${renderSections(item, 'totp')}
@@ -193,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'}
@@ -212,6 +220,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
+ ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
@@ -228,13 +237,31 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value; const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value; const labelVal = (document.getElementById('f-label') as HTMLInputElement).value; + // Preserve the disclosure's live expanded state across kind-toggle re-render. + const currentDisclosure = app.querySelector('.disclosure'); + if (currentDisclosure) { + sectionsExpanded = currentDisclosure.getAttribute('data-expanded') === 'true'; + } app.innerHTML = renderInner(); (document.getElementById('f-title') as HTMLInputElement).value = titleVal; (document.getElementById('f-secret') as HTMLInputElement).value = secretVal; (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 => { @@ -249,7 +276,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') { @@ -263,17 +291,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; } @@ -316,7 +344,7 @@ async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise { const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + // Fetch vault settings so subsequent screens (generator popover, + // settings-vault) can show current values without a round-trip. + // Failures swallow silently — list view still renders; consumers + // can show "settings not loaded" if needed. + const vsResp = await sendMessage({ type: 'get_vault_settings' }); + if (vsResp.ok) { + const vs = (vsResp.data as { settings: import('../shared/types').VaultSettings }).settings; + currentState.vaultSettings = vs; + currentState.generatorDefaults = vs.generator_defaults; + } navigate('list', { entries: listData.items }); return; } diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index c5b8976..7297e8f 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -508,3 +508,186 @@ textarea { .sig-block--green { border-left-color: #3fb950; } .sig-block--amber { border-left-color: #d29922; } .sig-block--red { border-left-color: #f85149; } + +/* --- custom-section rendering (β₂ slice 1) --- */ +.section-header { + margin-top: 14px; + margin-bottom: 4px; + padding-top: 10px; + border-top: 1px solid #21262d; + color: #8b949e; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.section-separator { + margin: 10px 0 4px; + border: 0; + border-top: 1px solid #21262d; +} + +/* --- custom-section editor (β₂ slice 2) --- */ +.disclosure { + border-top: 1px solid #21262d; + margin-top: 14px; + padding-top: 10px; +} +.disclosure__toggle { + background: transparent; border: 0; color: #58a6ff; + cursor: pointer; font-size: 12px; padding: 0; + font-family: inherit; +} +.disclosure[data-expanded="false"] .disclosure__body { display: none; } + +.section-editor__head { + display: flex; align-items: baseline; gap: 8px; + margin-top: 10px; margin-bottom: 4px; + font-size: 11px; +} +.section-editor__head .name { color: #c9d1d9; font-weight: 600; } +.section-editor__head .name.anon { color: #8b949e; font-style: italic; font-weight: normal; } +.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; } +.section-editor__head .actions button { + background: transparent; border: 0; color: inherit; + cursor: pointer; padding: 0; margin-left: 8px; + font: inherit; +} +.section-editor__head .actions button:hover { color: #c9d1d9; } + +.section-editor__field { + display: grid; grid-template-columns: 120px 1fr auto; + gap: 4px; margin-bottom: 4px; font-size: 11px; +} +.section-editor__field input { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px; +} +.section-editor__field .delete-field { + background: transparent; border: 0; color: #f85149; + cursor: pointer; font-size: 14px; padding: 0 4px; +} +.section-editor__preserved { + font-size: 10px; color: #6e7681; font-style: italic; + padding: 4px 0 4px 6px; +} + +.section-editor__add { + display: flex; gap: 6px; margin-top: 6px; +} +.section-editor__add button { + background: transparent; border: 1px solid #30363d; color: #8b949e; + padding: 2px 10px; border-radius: 3px; cursor: pointer; + font-size: 10px; font-family: inherit; +} +.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; } + +.disclosure__body .add-section { + margin-top: 12px; background: transparent; + border: 1px dashed #30363d; color: #8b949e; + padding: 6px 10px; border-radius: 4px; cursor: pointer; + width: 100%; font-size: 11px; font-family: inherit; +} +.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; } + +/* --- generator popover (β₂ slice 4) --- */ +.generator-popover { + position: absolute; z-index: 9999999; + background: #161b22; border: 1px solid #30363d; border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + padding: 14px; min-width: 300px; max-width: 340px; + font-size: 11px; font-family: system-ui, sans-serif; color: #c9d1d9; +} +.generator-popover .gen-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 8px; +} +.generator-popover .gen-title { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: lowercase; letter-spacing: 0.08em; } +.generator-popover .gen-close { + background: transparent; border: 0; color: #8b949e; cursor: pointer; + font-size: 14px; padding: 2px 6px; +} +.generator-popover .gen-row { + display: flex; align-items: center; gap: 8px; margin: 6px 0; +} +.generator-popover .gen-row__label { + color: #8b949e; width: 70px; flex-shrink: 0; + font-size: 10px; text-transform: lowercase; +} +.generator-popover .gen-toggle-group { + display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden; +} +.generator-popover .gen-toggle-group button { + background: transparent; border: 0; color: #8b949e; + padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px; +} +.generator-popover .gen-toggle-group button.active { background: #1f6feb; color: #fff; } +.generator-popover .gen-slider { flex: 1; } +.generator-popover .gen-slider + span { + color: #c9d1d9; font-variant-numeric: tabular-nums; + font-family: monospace; min-width: 24px; text-align: right; +} +.generator-popover .gen-check-grid { + display: grid; grid-template-columns: 1fr 1fr; + gap: 4px 16px; margin: 6px 0; font-size: 11px; +} +.generator-popover .gen-check-grid label { + display: flex; align-items: center; gap: 6px; +} +.generator-popover .gen-preview { + margin: 10px 0 8px; padding: 8px 10px; + background: #0d1117; border: 1px solid #30363d; border-radius: 4px; + font-family: "SF Mono", "JetBrains Mono", monospace; color: #c9d1d9; + display: flex; justify-content: space-between; align-items: center; gap: 8px; + word-break: break-all; +} +.generator-popover .gen-preview__regen { + flex-shrink: 0; background: transparent; border: 0; + color: #58a6ff; cursor: pointer; font-size: 12px; +} +.generator-popover .gen-actions { + display: grid; grid-template-columns: 1fr 1fr; + gap: 6px; margin-top: 10px; +} +.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; } + +/* --- settings-vault screen (β₂ slice 5) --- */ +.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.settings-section { + margin-top: 14px; padding-top: 10px; + border-top: 1px solid #21262d; +} +.settings-section__title { + color: #8b949e; font-size: 10px; + text-transform: uppercase; letter-spacing: 0.08em; + margin-bottom: 6px; +} +.settings-row { + display: grid; grid-template-columns: 110px 1fr; + gap: 6px 10px; align-items: center; + margin: 4px 0; font-size: 12px; +} +.settings-row__label { color: #8b949e; } +.settings-row select { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px; +} +.gen-preview-line { + margin: 0 0 6px; font-size: 11px; color: #c9d1d9; + font-family: "SF Mono", "JetBrains Mono", monospace; +} +.ack-row { + display: grid; grid-template-columns: 1fr auto auto; + gap: 8px; align-items: center; + padding: 4px 0; font-size: 11px; + border-bottom: 1px solid #161b22; +} +.ack-row__host { color: #c9d1d9; font-family: monospace; } +.ack-row__meta { color: #6e7681; font-size: 10px; } +.ack-row__revoke { + background: transparent; border: 0; color: #f85149; + cursor: pointer; font-size: 10px; +} +.settings-footer { + display: flex; justify-content: flex-end; gap: 6px; + margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d; +} diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index fa49416..03848de 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -654,3 +654,81 @@ describe('get_totp handler covers both Login.totp and Totp.config', () => { expect(res).toEqual({ ok: false, error: 'no_totp' }); }); }); + +// --- get_vault_settings / update_vault_settings (β₂ Slice 3) --- + +describe('get_vault_settings / update_vault_settings', () => { + function primeUnlocked(state: RouterState): void { + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptSettings).mockReset(); + vi.mocked(vault.encryptAndWriteSettings).mockReset(); + }); + + it('get_vault_settings accepted from popup; returns VaultSettings', async () => { + const state = makeState(); + primeUnlocked(state); + const mockSettings = { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: { + kind: 'random', length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, + }, + attachment_caps: {}, + autofill_origin_acks: { 'github.com': 1000 }, + }; + vi.mocked(vault.fetchAndDecryptSettings).mockResolvedValueOnce(mockSettings as never); + const res = await route({ type: 'get_vault_settings' }, state, makePopupSender()); + expect(res).toMatchObject({ ok: true }); + if (res.ok) { + const d = res.data as { settings: typeof mockSettings }; + expect(d.settings).toEqual(mockSettings); + } + }); + + it('get_vault_settings rejected from content', async () => { + const state = makeState(); + const res = await route({ type: 'get_vault_settings' }, state, makeContentSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('update_vault_settings accepted from popup; calls encryptAndWriteSettings', async () => { + const state = makeState(); + primeUnlocked(state); + vi.mocked(vault.encryptAndWriteSettings).mockResolvedValueOnce(undefined); + const newSettings = { + trash_retention: { kind: 'forever' }, + field_history_retention: { kind: 'last_n', value: 5 }, + generator_defaults: { + kind: 'bip39', word_count: 6, separator: '-', capitalization: 'lower', + }, + attachment_caps: {}, + autofill_origin_acks: {}, + }; + const res = await route( + { type: 'update_vault_settings', settings: newSettings as never }, + state, + makePopupSender(), + ); + expect(res).toMatchObject({ ok: true }); + expect(vault.encryptAndWriteSettings).toHaveBeenCalledWith( + expect.anything(), expect.anything(), newSettings, expect.any(String), + ); + }); + + it('update_vault_settings rejected from setup tab (not in SETUP_ALLOWED)', async () => { + const state = makeState(); + const res = await route( + { type: 'update_vault_settings', settings: {} as never }, + state, + makeSetupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 14b89cc..58aa66c 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -149,6 +149,11 @@ export async function handle( return { ok: true, data: { password } }; } + case 'generate_passphrase': { + const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request)); + return { ok: true, data: { passphrase } }; + } + case 'fill_credentials': return handleFillCredentials(msg, state); @@ -171,6 +176,23 @@ export async function handle( return { ok: true }; } + case 'get_vault_settings': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + return { ok: true, data: { settings } }; + } + + case 'update_vault_settings': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + await vault.encryptAndWriteSettings( + state.gitHost, handle, msg.settings, + 'settings: update vault-level config', + ); + return { ok: true }; + } + case 'get_blacklist': return { ok: true, data: { blacklist: await loadBlacklist() } }; diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 3001b9f..2654bca 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,6 +1,6 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, - DeviceSettings, GeneratorRequest, + DeviceSettings, GeneratorRequest, VaultSettings, } from './types'; // --- Messages a popup (or setup page) may send --- @@ -20,10 +20,13 @@ export type PopupMessage = | { type: 'save_setup'; config: VaultConfig; imageBase64: string } | { type: 'rate_passphrase'; passphrase: string } | { type: 'generate_password'; request: GeneratorRequest } + | { type: 'generate_passphrase'; request: GeneratorRequest } | { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string } | { type: 'ack_autofill_origin'; hostname: string } | { type: 'get_settings' } | { type: 'update_settings'; settings: Partial } + | { type: 'get_vault_settings' } + | { type: 'update_vault_settings'; settings: VaultSettings } | { type: 'get_blacklist' } | { type: 'remove_blacklist'; hostname: string }; @@ -88,13 +91,19 @@ export interface RatePassphraseResponse extends Extract data: { score: number; guesses_log10: number }; } +export interface VaultSettingsResponse extends Extract { + data: { settings: VaultSettings }; +} + // --- Capability sets (consumed by the router) --- export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item', 'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state', - 'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials', - 'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist', + 'save_setup', 'rate_passphrase', 'generate_password', 'generate_passphrase', + 'fill_credentials', + 'ack_autofill_origin', 'get_settings', 'update_settings', + 'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'remove_blacklist', ] as PopupMessage['type'][]); diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index e2b7340..7893e38 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -183,15 +183,24 @@ export interface ManifestEntry { attachment_summaries: AttachmentSummary[]; } -// --- Vault settings (only the fields α touches) --- +// --- Vault settings --- // Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md -// We leave retention/generator/caps opaque to α so we don't accidentally mutate them. +// β₂ tightens retention + generator_defaults; γ owns attachment_caps. + +export type TrashRetention = + | { kind: 'forever' } + | { kind: 'days'; value: number }; + +export type HistoryRetention = + | { kind: 'forever' } + | { kind: 'last_n'; value: number } + | { kind: 'days'; value: number }; export interface VaultSettings { - trash_retention: unknown; - field_history_retention: unknown; - generator_defaults: unknown; - attachment_caps: unknown; + trash_retention: TrashRetention; + field_history_retention: HistoryRetention; + generator_defaults: GeneratorRequest; + attachment_caps: unknown; // opaque — γ tightens autofill_origin_acks: Record; }