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);
+ });
+
+ it('renders anonymous section with separator not header', () => {
+ 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);
+ });
+});
diff --git a/extension/src/popup/components/__tests__/settings-vault.test.ts b/extension/src/popup/components/__tests__/settings-vault.test.ts
new file mode 100644
index 0000000..7e836c7
--- /dev/null
+++ b/extension/src/popup/components/__tests__/settings-vault.test.ts
@@ -0,0 +1,98 @@
+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: '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 += ``;
+ } 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 `
+
+
+
kind
+
+
+
+
+
+ ${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
+
+
+
+
+ ${knobs.kind === 'random'
+ ? `pick at least one character class
`
+ : ''}
+
+
+
+
+
+
+ `;
+}
+
+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 = `
+
+
+
+
+
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')}