Merge Plan 1C-β₂: custom fields + settings + generator UI
Final β sub-plan. Adds three cross-cutting UI surfaces on top of β₁'s
typed-item forms:
- Custom-fields editor: collapsible disclosure in every type's edit
form; sections + fields of kind text/password/concealed (other 8
FieldKinds preserved untouched on save). Always-visible below typed
rows in detail mode. Add/remove sections + fields, rename sections.
- Generator inline popover: invoked at every gen-button. Random vs
BIP39 toggle, length/word-count slider, charset checkboxes, live
preview on 150ms debounce. Actions: use-this-value / save-as-default
/ reset-to-defaults / cancel. Shared with the Settings 'configure'
button.
- Full VaultSettings view: trash + field-history retention picks,
generator-default summary + 'configure' link, autofill origin-ack
list with per-host revoke. Save / discard with deep-equal dirty check.
- Two new popup-only messages (get/update_vault_settings) wrapping
α's existing fetchAndDecrypt/encryptAndWriteSettings. NOT in
SETUP_ALLOWED.
- generate_passphrase popup-only message + handler (BIP39 preview).
- VaultSettings TS types tightened (TrashRetention/HistoryRetention
tagged unions; generator_defaults typed as GeneratorRequest;
attachment_caps still opaque pending γ).
- ⚙ toolbar button now opens a 2-option picker (device / vault).
Five-slice execution: 13 commits + 1 mid-slice fix for unsupported-kind
field preservation + Totp kind-toggle disclosure-state. Tests 84 → 124
Vitest (+40); 155 Rust unchanged. Both Chrome + Firefox bundles
compile clean. All lint greps clean.
Tag plan-1c-beta2-complete points at fba50b8 (branch tip).
This commit is contained in:
@@ -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 = '<button id="anchor">gen</button>';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
199
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal file
199
extension/src/popup/components/__tests__/sections-editor.test.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
104
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
104
extension/src/popup/components/__tests__/sections-render.test.ts
Normal file
@@ -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</);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, '"').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 = '<div id="app"></div>';
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
/// copy click handlers on any rendered rows.
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
import { escapeHtml } from '../popup';
|
import { escapeHtml } from '../popup';
|
||||||
|
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||||||
|
|
||||||
export interface RowOpts {
|
export interface RowOpts {
|
||||||
label: string;
|
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 += `<div class="section-header">${escapeHtml(section.name)}</div>`;
|
||||||
|
} else {
|
||||||
|
out += `<hr class="section-separator">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
|
||||||
|
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
|
||||||
|
<div class="disclosure__body">
|
||||||
|
${body}
|
||||||
|
<button type="button" class="add-section">+ add section</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSectionBlock(section: Section, sIdx: number): string {
|
||||||
|
const nameDisplay = section.name
|
||||||
|
? `<span class="name">${escapeHtml(section.name)}</span>`
|
||||||
|
: `<span class="name anon">(anonymous)</span>`;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
? `<div class="section-editor__preserved">${preservedCount} field${preservedCount === 1 ? '' : 's'} of unsupported kind (edit via CLI)</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section-editor" data-section-idx="${sIdx}">
|
||||||
|
<div class="section-editor__head">
|
||||||
|
${nameDisplay}
|
||||||
|
<span class="actions">
|
||||||
|
<button type="button" data-rename-section="${sIdx}">rename</button>
|
||||||
|
<button type="button" data-remove-section="${sIdx}">× remove section</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${fieldsHtml}
|
||||||
|
${preservedNote}
|
||||||
|
<div class="section-editor__add">
|
||||||
|
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
|
||||||
|
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
|
||||||
|
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="section-editor__field">
|
||||||
|
<input type="text" data-field-label="${escapeHtml(field.id)}" value="${escapeHtml(field.label)}" placeholder="label">
|
||||||
|
<input type="${inputType}" data-field-value-input="${escapeHtml(field.id)}" value="${escapeHtml(valueStr)}" placeholder="value">
|
||||||
|
<button type="button" class="delete-field" data-delete-field="${escapeHtml(field.id)}" data-section-idx="${sIdx}">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('[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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
350
extension/src/popup/components/generator-popover.ts
Normal file
350
extension/src/popup/components/generator-popover.ts
Normal file
@@ -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<typeof setTimeout> | 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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-separator]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.separator = btn.dataset.separator ?? ' ';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[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 `
|
||||||
|
<div class="gen-header">
|
||||||
|
<span class="gen-title">generate</span>
|
||||||
|
<button type="button" id="gen-close" class="gen-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">kind</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
<button id="gen-kind-random" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
|
||||||
|
<button id="gen-kind-bip39" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
|
||||||
|
<div class="gen-preview">
|
||||||
|
<span class="gen-preview__value"></span>
|
||||||
|
<button type="button" class="gen-preview__regen" title="regenerate">↻</button>
|
||||||
|
</div>
|
||||||
|
${knobs.kind === 'random'
|
||||||
|
? `<p class="gen-validation" style="display:none;color:#f85149;font-size:10px;margin:4px 0 0;">pick at least one character class</p>`
|
||||||
|
: ''}
|
||||||
|
<div class="gen-actions">
|
||||||
|
<button type="button" class="btn" id="gen-reset">reset to defaults</button>
|
||||||
|
<button type="button" class="btn" id="gen-save-default">save as default</button>
|
||||||
|
<button type="button" class="btn" id="gen-cancel">cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="gen-use">use this value</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRandomKnobs(k: UiKnobs): string {
|
||||||
|
return `
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">length</span>
|
||||||
|
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="gen-slider">
|
||||||
|
<span id="gen-length-val">${k.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gen-check-grid">
|
||||||
|
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
|
||||||
|
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
|
||||||
|
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
|
||||||
|
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">symbols</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
<button data-symbol-charset="safe_only" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
||||||
|
<button data-symbol-charset="extended" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBip39Knobs(k: UiKnobs): string {
|
||||||
|
const sepChip = (label: string, sep: string) => `
|
||||||
|
<button data-separator="${sep}" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
const capChip = (label: string, val: string) => `
|
||||||
|
<button data-capitalization="${val}" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
return `
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">words</span>
|
||||||
|
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="gen-slider">
|
||||||
|
<span id="gen-word-count-val">${k.wordCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">separator</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
${sepChip('space', ' ')}
|
||||||
|
${sepChip('-', '-')}
|
||||||
|
${sepChip('_', '_')}
|
||||||
|
${sepChip('.', '.')}
|
||||||
|
${sepChip(':', ':')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">case</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
${capChip('lower', 'lower')}
|
||||||
|
${capChip('upper', 'upper')}
|
||||||
|
${capChip('first', 'first_of_each')}
|
||||||
|
${capChip('title', 'title')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -93,7 +93,10 @@ export function renderItemList(app: HTMLElement): void {
|
|||||||
navigate('locked');
|
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.
|
// Item row clicks.
|
||||||
const rows = app.querySelectorAll('.entry-row');
|
const rows = app.querySelectorAll('.entry-row');
|
||||||
@@ -307,3 +310,80 @@ function showNewTypePicker(anchor: HTMLElement): void {
|
|||||||
document.addEventListener('keydown', closeOnEsc);
|
document.addEventListener('keydown', closeOnEsc);
|
||||||
}, 0);
|
}, 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);
|
||||||
|
}
|
||||||
|
|||||||
236
extension/src/popup/components/settings-vault.ts
Normal file
236
extension/src/popup/components/settings-vault.ts
Normal file
@@ -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 = `<div class="pad"><p class="muted">Vault settings not loaded yet.</p></div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="pad">
|
||||||
|
<div class="settings-header">
|
||||||
|
<button class="btn" id="back-btn">← back</button>
|
||||||
|
<h3 style="margin:0;">vault settings</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">retention</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">trash</span>
|
||||||
|
<select id="trash-retention">
|
||||||
|
<option value="forever">Forever</option>
|
||||||
|
<option value="days:7">7 days</option>
|
||||||
|
<option value="days:30">30 days</option>
|
||||||
|
<option value="days:60">60 days</option>
|
||||||
|
<option value="days:90">90 days</option>
|
||||||
|
<option value="days:180">180 days</option>
|
||||||
|
<option value="days:365">365 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-row__label">field history</span>
|
||||||
|
<select id="history-retention">
|
||||||
|
<option value="forever">Forever</option>
|
||||||
|
<option value="last_n:3">Last 3</option>
|
||||||
|
<option value="last_n:5">Last 5</option>
|
||||||
|
<option value="last_n:10">Last 10</option>
|
||||||
|
<option value="days:30">30 days</option>
|
||||||
|
<option value="days:90">90 days</option>
|
||||||
|
<option value="days:365">365 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">generator</div>
|
||||||
|
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
|
||||||
|
<button class="btn" id="configure-gen">configure ▾</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section__title">autofill origins</div>
|
||||||
|
${acksEntries.length === 0
|
||||||
|
? `<p class="muted">No origins acknowledged yet.</p>`
|
||||||
|
: acksEntries.map(([host, ts]) => `
|
||||||
|
<div class="ack-row">
|
||||||
|
<span class="ack-row__host">${escapeHtml(host)}</span>
|
||||||
|
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
|
||||||
|
<button class="ack-row__revoke" data-revoke="${escapeHtml(host)}">revoke</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button class="btn" id="discard-btn">discard</button>
|
||||||
|
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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<HTMLButtonElement>('[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);
|
||||||
|
}
|
||||||
@@ -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, '"').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 = '<div id="app"></div>';
|
||||||
|
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}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
/// Detail view has a styled card-silhouette signature block.
|
/// Detail view has a styled card-silhouette signature block.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
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 {
|
import {
|
||||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
|
||||||
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
|
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
@@ -21,6 +23,7 @@ export function teardown(): void {
|
|||||||
document.removeEventListener('keydown', activeFormEscHandler);
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
activeFormEscHandler = null;
|
activeFormEscHandler = null;
|
||||||
}
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function brandFromNumber(num: string): string {
|
function brandFromNumber(num: string): string {
|
||||||
@@ -79,6 +82,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
</div>
|
</div>
|
||||||
${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''}
|
${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 }) : ''}
|
${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''}
|
||||||
|
${renderSections(item, 'card')}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -139,6 +143,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const c = (existing?.core.type === 'card') ? existing.core : null;
|
const c = (existing?.core.type === 'card') ? existing.core : null;
|
||||||
const currentYear = new Date().getFullYear();
|
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 monthOptions = Array.from({ length: 12 }, (_, i) => {
|
||||||
const m = String(i + 1).padStart(2, '0');
|
const m = String(i + 1).padStart(2, '0');
|
||||||
const sel = c?.expiry?.month === i + 1 ? 'selected' : '';
|
const sel = c?.expiry?.month === i + 1 ? 'selected' : '';
|
||||||
@@ -175,6 +183,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<input id="f-pin" type="password" inputmode="numeric" maxlength="8" value="${escapeHtml(c?.pin ?? '')}"></div>
|
<input id="f-pin" type="password" inputmode="numeric" maxlength="8" value="${escapeHtml(c?.pin ?? '')}"></div>
|
||||||
<div class="form-group"><label class="label" for="f-kind">kind</label>
|
<div class="form-group"><label class="label" for="f-kind">kind</label>
|
||||||
<select id="f-kind">${kindOptions}</select></div>
|
<select id="f-kind">${kindOptions}</select></div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -182,12 +191,21 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveCard(mode, existing);
|
await saveCard(mode, existing, sectionsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
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();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
async function saveCard(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -241,7 +259,7 @@ async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise<vo
|
|||||||
created: existing?.created ?? now,
|
created: existing?.created ?? now,
|
||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: existing?.sections ?? [],
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: existing?.attachments ?? [],
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
/// Detail view shows a "profile card" signature block + plain rows.
|
/// Detail view shows a "profile card" signature block + plain rows.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
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 {
|
import {
|
||||||
renderRow, renderSignatureBlock, wireFieldHandlers,
|
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
@@ -19,6 +21,7 @@ export function teardown(): void {
|
|||||||
document.removeEventListener('keydown', activeFormEscHandler);
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
activeFormEscHandler = null;
|
activeFormEscHandler = null;
|
||||||
}
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initials(name: string | undefined): string {
|
function initials(name: string | undefined): string {
|
||||||
@@ -57,6 +60,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${c.email ? renderRow({ label: 'email', value: c.email, copyable: true }) : ''}
|
${c.email ? renderRow({ label: 'email', value: c.email, copyable: true }) : ''}
|
||||||
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
|
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
|
||||||
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
|
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
|
||||||
|
${renderSections(item, 'identity')}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -114,6 +118,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const title = existing?.title ?? '';
|
const title = existing?.title ?? '';
|
||||||
const c = (existing?.core.type === 'identity') ? existing.core : null;
|
const c = (existing?.core.type === 'identity') ? existing.core : null;
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
|
||||||
@@ -130,6 +138,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<textarea id="f-address" rows="3" placeholder="street, city, postcode...">${escapeHtml(c?.address ?? '')}</textarea></div>
|
<textarea id="f-address" rows="3" placeholder="street, city, postcode...">${escapeHtml(c?.address ?? '')}</textarea></div>
|
||||||
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
|
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
|
||||||
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
|
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -137,12 +146,21 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveIdentity(mode, existing);
|
await saveIdentity(mode, existing, sectionsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
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();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
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,
|
created: existing?.created ?? now,
|
||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: existing?.sections ?? [],
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: existing?.attachments ?? [],
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
/// since <textarea type="password"> isn't a thing.
|
/// since <textarea type="password"> isn't a thing.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
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 {
|
import {
|
||||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
@@ -20,6 +22,7 @@ export function teardown(): void {
|
|||||||
document.removeEventListener('keydown', activeFormEscHandler);
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
activeFormEscHandler = null;
|
activeFormEscHandler = null;
|
||||||
}
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||||
@@ -42,6 +45,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
||||||
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
|
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
|
||||||
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
|
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
|
||||||
|
${renderSections(item, 'key')}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -103,6 +107,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const title = existing?.title ?? '';
|
const title = existing?.title ?? '';
|
||||||
const c = (existing?.core.type === 'key') ? existing.core : null;
|
const c = (existing?.core.type === 'key') ? existing.core : null;
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</div>
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</div>
|
||||||
@@ -120,6 +128,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<input id="f-algorithm" type="text" value="${escapeHtml(c?.algorithm ?? '')}" placeholder="ed25519"></div>
|
<input id="f-algorithm" type="text" value="${escapeHtml(c?.algorithm ?? '')}" placeholder="ed25519"></div>
|
||||||
<div class="form-group"><label class="label" for="f-public-key">public key</label>
|
<div class="form-group"><label class="label" for="f-public-key">public key</label>
|
||||||
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
|
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -127,6 +136,15 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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.
|
// Show/hide toggle for the key_material textarea.
|
||||||
let revealed = false;
|
let revealed = false;
|
||||||
document.getElementById('key-show-btn')?.addEventListener('click', () => {
|
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');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveKey(mode, existing);
|
await saveKey(mode, existing, sectionsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
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();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
async function saveKey(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -184,7 +202,7 @@ async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise<voi
|
|||||||
created: existing?.created ?? now,
|
created: existing?.created ?? now,
|
||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: existing?.sections ?? [],
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: existing?.attachments ?? [],
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/// field helpers introduced in Slice 2.
|
/// field helpers introduced in Slice 2.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||||
import type { Item, ItemId, LoginCore, ManifestEntry, TotpConfig } from '../../../shared/types';
|
import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig } from '../../../shared/types';
|
||||||
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
||||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
renderConcealedRow,
|
renderConcealedRow,
|
||||||
renderSignatureBlock,
|
renderSignatureBlock,
|
||||||
wireFieldHandlers,
|
wireFieldHandlers,
|
||||||
|
renderSections,
|
||||||
|
renderSectionsEditor,
|
||||||
|
wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
|
||||||
|
|
||||||
/// Called by the dispatcher before each render. Stops any in-flight
|
/// Called by the dispatcher before each render. Stops any in-flight
|
||||||
/// tickers / intervals / listeners the previous view may have attached.
|
/// tickers / intervals / listeners the previous view may have attached.
|
||||||
@@ -24,6 +28,8 @@ export function teardown(): void {
|
|||||||
document.removeEventListener('keydown', activeFormEscHandler);
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
activeFormEscHandler = null;
|
activeFormEscHandler = null;
|
||||||
}
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
|
closeGeneratorPopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@@ -61,6 +67,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
|
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
|
||||||
|
${renderSections(item, 'login')}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -179,6 +186,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
function stopTotpTicker(): void {
|
function stopTotpTicker(): void {
|
||||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
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 group = existing?.group ?? '';
|
||||||
const notes = existing?.notes ?? '';
|
const notes = existing?.notes ?? '';
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||||
@@ -236,6 +248,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
||||||
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
||||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
@@ -243,14 +256,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
const rerender = (): void => {
|
||||||
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
|
const disclosure = app.querySelector('.disclosure');
|
||||||
if (resp.ok) {
|
if (!disclosure) return;
|
||||||
const data = resp.data as { password: string };
|
sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true';
|
||||||
const pw = document.getElementById('f-password') as HTMLInputElement;
|
disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded);
|
||||||
pw.value = data.password;
|
wireSectionsEditor(app, sectionsDraft, rerender);
|
||||||
pw.type = 'text';
|
};
|
||||||
} else setState({ error: resp.error });
|
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', () => {
|
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 () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveLogin(mode, existing);
|
await saveLogin(mode, existing, sectionsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
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<void> {
|
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||||
@@ -337,7 +362,7 @@ async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<v
|
|||||||
modified: now,
|
modified: now,
|
||||||
trashed_at: undefined,
|
trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: existing?.sections ?? [],
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: existing?.attachments ?? [],
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
/// detail view; the form is just a big <textarea>.
|
/// detail view; the form is just a big <textarea>.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
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 {
|
import {
|
||||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
export function teardown(): void {
|
export function teardown(): void {
|
||||||
if (activeKeyHandler) {
|
if (activeKeyHandler) {
|
||||||
@@ -19,6 +21,7 @@ export function teardown(): void {
|
|||||||
document.removeEventListener('keydown', activeFormEscHandler);
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
activeFormEscHandler = null;
|
activeFormEscHandler = null;
|
||||||
}
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
|
||||||
@@ -35,6 +38,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${renderSignatureBlock({ accent: 'green', children: sigInner })}
|
${renderSignatureBlock({ accent: 'green', children: sigInner })}
|
||||||
</div>
|
</div>
|
||||||
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
|
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
|
||||||
|
${renderSections(item, 'secure-note')}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -92,6 +96,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const title = existing?.title ?? '';
|
const title = existing?.title ?? '';
|
||||||
const body = (existing?.core.type === 'secure_note') ? existing.core.body ?? '' : '';
|
const body = (existing?.core.type === 'secure_note') ? existing.core.body ?? '' : '';
|
||||||
|
|
||||||
|
const sectionsDraft: Section[] = existing
|
||||||
|
? JSON.parse(JSON.stringify(existing.sections)) as Section[]
|
||||||
|
: [];
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
|
||||||
@@ -100,6 +108,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
|
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
|
||||||
<div class="form-group"><label class="label" for="f-body">body</label>
|
<div class="form-group"><label class="label" for="f-body">body</label>
|
||||||
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
|
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -107,12 +116,21 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||||
await saveSecureNote(mode, existing);
|
await saveSecureNote(mode, existing, sectionsDraft);
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
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();
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
const body = (document.getElementById('f-body') as HTMLTextAreaElement).value;
|
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,
|
modified: now,
|
||||||
trashed_at: undefined,
|
trashed_at: undefined,
|
||||||
core: { type: 'secure_note', body },
|
core: { type: 'secure_note', body },
|
||||||
sections: existing?.sections ?? [],
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: existing?.attachments ?? [],
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
/// (TOTP vs Steam Guard) and a single secret input.
|
/// (TOTP vs Steam Guard) and a single secret input.
|
||||||
|
|
||||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
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 { base32Decode, base32Encode } from '../../../shared/base32';
|
||||||
import {
|
import {
|
||||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
|
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||||
|
renderSectionsEditor, wireSectionsEditor,
|
||||||
} from '../fields';
|
} from '../fields';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let sectionsExpanded = false;
|
||||||
|
|
||||||
function stopTotpTicker(): void {
|
function stopTotpTicker(): void {
|
||||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
||||||
@@ -33,6 +35,7 @@ export function teardown(): void {
|
|||||||
document.removeEventListener('keydown', activeFormEscHandler);
|
document.removeEventListener('keydown', activeFormEscHandler);
|
||||||
activeFormEscHandler = null;
|
activeFormEscHandler = null;
|
||||||
}
|
}
|
||||||
|
sectionsExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@@ -83,6 +86,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
|
|||||||
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
|
||||||
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
|
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
|
||||||
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
|
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
|
||||||
|
${renderSections(item, 'totp')}
|
||||||
<div class="form-actions" style="margin-top:14px;">
|
<div class="form-actions" style="margin-top:14px;">
|
||||||
<button class="btn" id="back-btn">back</button>
|
<button class="btn" id="back-btn">back</button>
|
||||||
<button class="btn" id="edit-btn">edit</button>
|
<button class="btn" id="edit-btn">edit</button>
|
||||||
@@ -193,6 +197,10 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
formKind = c?.config.kind === 'steam' ? 'steam' : 'totp';
|
formKind = c?.config.kind === 'steam' ? 'steam' : 'totp';
|
||||||
const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : '';
|
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 => `
|
const renderInner = (): string => `
|
||||||
<div class="pad">
|
<div class="pad">
|
||||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
|
||||||
@@ -212,6 +220,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
|
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
|
||||||
<div class="form-group"><label class="label" for="f-label">label</label>
|
<div class="form-group"><label class="label" for="f-label">label</label>
|
||||||
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
|
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
|
||||||
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">save</button>
|
<button class="btn btn-primary" id="save-btn">save</button>
|
||||||
@@ -228,13 +237,31 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
|
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
|
||||||
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
|
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
|
||||||
const labelVal = (document.getElementById('f-label') 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();
|
app.innerHTML = renderInner();
|
||||||
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
|
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
|
||||||
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
|
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
|
||||||
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
|
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
|
||||||
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
|
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
|
||||||
wireKindToggle();
|
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 => {
|
const wireKindToggle = (): void => {
|
||||||
@@ -249,7 +276,8 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
|||||||
};
|
};
|
||||||
|
|
||||||
wireKindToggle();
|
wireKindToggle();
|
||||||
wireFormButtons(mode, existing);
|
wireFormButtons(mode, existing, sectionsDraft);
|
||||||
|
wireSectionsEditor(app, sectionsDraft, sectionsRerender);
|
||||||
|
|
||||||
const escHandler = (e: KeyboardEvent) => {
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
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();
|
(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', () => {
|
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||||||
setState({ error: null });
|
setState({ error: null });
|
||||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||||
});
|
});
|
||||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
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<void> {
|
async function saveTotp(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[]): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||||
if (!title) { setState({ error: 'Title is required' }); return; }
|
if (!title) { setState({ error: 'Title is required' }); return; }
|
||||||
@@ -316,7 +344,7 @@ async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise<vo
|
|||||||
created: existing?.created ?? now,
|
created: existing?.created ?? now,
|
||||||
modified: now, trashed_at: undefined,
|
modified: now, trashed_at: undefined,
|
||||||
core,
|
core,
|
||||||
sections: existing?.sections ?? [],
|
sections: sectionsDraft,
|
||||||
attachments: existing?.attachments ?? [],
|
attachments: existing?.attachments ?? [],
|
||||||
field_history: existing?.field_history ?? {},
|
field_history: existing?.field_history ?? {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Popup entry point — state machine with view routing.
|
/// Popup entry point — state machine with view routing.
|
||||||
///
|
///
|
||||||
/// Views: setup | locked | list | detail | add | edit
|
/// Views: setup | locked | list | detail | add | edit | settings | settings-vault
|
||||||
/// Navigation works by updating `currentState` and calling `render()`.
|
/// Navigation works by updating `currentState` and calling `render()`.
|
||||||
|
|
||||||
import type { Request, Response } from '../shared/messages';
|
import type { Request, Response } from '../shared/messages';
|
||||||
@@ -10,6 +10,7 @@ import { renderItemList } from './components/item-list';
|
|||||||
import { renderItemDetail } from './components/item-detail';
|
import { renderItemDetail } from './components/item-detail';
|
||||||
import { renderItemForm } from './components/item-form';
|
import { renderItemForm } from './components/item-form';
|
||||||
import { renderSettings } from './components/settings';
|
import { renderSettings } from './components/settings';
|
||||||
|
import { renderVaultSettings } from './components/settings-vault';
|
||||||
|
|
||||||
// --- Escape HTML to prevent XSS ---
|
// --- Escape HTML to prevent XSS ---
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: string): string {
|
||||||
@@ -23,7 +24,7 @@ export function escapeHtml(str: string): string {
|
|||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
||||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
|
||||||
|
|
||||||
export interface PopupState {
|
export interface PopupState {
|
||||||
view: View;
|
view: View;
|
||||||
@@ -42,6 +43,8 @@ export interface PopupState {
|
|||||||
capturedTabId: number | null;
|
capturedTabId: number | null;
|
||||||
capturedUrl: string;
|
capturedUrl: string;
|
||||||
newType: import('../shared/types').ItemType | null;
|
newType: import('../shared/types').ItemType | null;
|
||||||
|
vaultSettings: import('../shared/types').VaultSettings | null;
|
||||||
|
generatorDefaults: import('../shared/types').GeneratorRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentState: PopupState = {
|
let currentState: PopupState = {
|
||||||
@@ -57,6 +60,8 @@ let currentState: PopupState = {
|
|||||||
capturedTabId: null,
|
capturedTabId: null,
|
||||||
capturedUrl: '',
|
capturedUrl: '',
|
||||||
newType: null,
|
newType: null,
|
||||||
|
vaultSettings: null,
|
||||||
|
generatorDefaults: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getState(): PopupState {
|
export function getState(): PopupState {
|
||||||
@@ -142,6 +147,9 @@ function render(): void {
|
|||||||
case 'settings':
|
case 'settings':
|
||||||
renderSettings(app);
|
renderSettings(app);
|
||||||
break;
|
break;
|
||||||
|
case 'settings-vault':
|
||||||
|
renderVaultSettings(app);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +183,16 @@ async function init(): Promise<void> {
|
|||||||
const listResp = await sendMessage({ type: 'list_items' });
|
const listResp = await sendMessage({ type: 'list_items' });
|
||||||
if (listResp.ok) {
|
if (listResp.ok) {
|
||||||
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
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 });
|
navigate('list', { entries: listData.items });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,3 +508,186 @@ textarea {
|
|||||||
.sig-block--green { border-left-color: #3fb950; }
|
.sig-block--green { border-left-color: #3fb950; }
|
||||||
.sig-block--amber { border-left-color: #d29922; }
|
.sig-block--amber { border-left-color: #d29922; }
|
||||||
.sig-block--red { border-left-color: #f85149; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -654,3 +654,81 @@ describe('get_totp handler covers both Login.totp and Totp.config', () => {
|
|||||||
expect(res).toEqual({ ok: false, error: 'no_totp' });
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ export async function handle(
|
|||||||
return { ok: true, data: { password } };
|
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':
|
case 'fill_credentials':
|
||||||
return handleFillCredentials(msg, state);
|
return handleFillCredentials(msg, state);
|
||||||
|
|
||||||
@@ -171,6 +176,23 @@ export async function handle(
|
|||||||
return { ok: true };
|
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':
|
case 'get_blacklist':
|
||||||
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||||||
DeviceSettings, GeneratorRequest,
|
DeviceSettings, GeneratorRequest, VaultSettings,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Messages a popup (or setup page) may send ---
|
// --- Messages a popup (or setup page) may send ---
|
||||||
@@ -20,10 +20,13 @@ export type PopupMessage =
|
|||||||
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
|
||||||
| { type: 'rate_passphrase'; passphrase: string }
|
| { type: 'rate_passphrase'; passphrase: string }
|
||||||
| { type: 'generate_password'; request: GeneratorRequest }
|
| { type: 'generate_password'; request: GeneratorRequest }
|
||||||
|
| { type: 'generate_passphrase'; request: GeneratorRequest }
|
||||||
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
|
||||||
| { type: 'ack_autofill_origin'; hostname: string }
|
| { type: 'ack_autofill_origin'; hostname: string }
|
||||||
| { type: 'get_settings' }
|
| { type: 'get_settings' }
|
||||||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||||||
|
| { type: 'get_vault_settings' }
|
||||||
|
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||||
| { type: 'get_blacklist' }
|
| { type: 'get_blacklist' }
|
||||||
| { type: 'remove_blacklist'; hostname: string };
|
| { type: 'remove_blacklist'; hostname: string };
|
||||||
|
|
||||||
@@ -88,13 +91,19 @@ export interface RatePassphraseResponse extends Extract<Response, { ok: true }>
|
|||||||
data: { score: number; guesses_log10: number };
|
data: { score: number; guesses_log10: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
||||||
|
data: { settings: VaultSettings };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Capability sets (consumed by the router) ---
|
// --- Capability sets (consumed by the router) ---
|
||||||
|
|
||||||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||||
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
||||||
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
||||||
'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
|
'save_setup', 'rate_passphrase', 'generate_password', 'generate_passphrase',
|
||||||
'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
|
'fill_credentials',
|
||||||
|
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||||
|
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||||
'remove_blacklist',
|
'remove_blacklist',
|
||||||
] as PopupMessage['type'][]);
|
] as PopupMessage['type'][]);
|
||||||
|
|
||||||
|
|||||||
@@ -183,15 +183,24 @@ export interface ManifestEntry {
|
|||||||
attachment_summaries: AttachmentSummary[];
|
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
|
// 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 {
|
export interface VaultSettings {
|
||||||
trash_retention: unknown;
|
trash_retention: TrashRetention;
|
||||||
field_history_retention: unknown;
|
field_history_retention: HistoryRetention;
|
||||||
generator_defaults: unknown;
|
generator_defaults: GeneratorRequest;
|
||||||
attachment_caps: unknown;
|
attachment_caps: unknown; // opaque — γ tightens
|
||||||
autofill_origin_acks: Record<string, number>;
|
autofill_origin_acks: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user