feat(extension): settings pane revamp — synced/local split + session timeout UI

This commit is contained in:
adlee-was-taken
2026-05-30 01:25:08 -04:00
parent 1edfa67a51
commit 299e7db1ab
4 changed files with 204 additions and 78 deletions

View File

@@ -40,19 +40,30 @@ describe('settings-vault', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>'; document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset(); vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true }); // Default: get_session_config returns inactivity/15, everything else ok
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
if (msg.type === 'get_session_config') {
return { ok: true, data: { config: { mode: 'inactivity', minutes: 15 } } };
}
return { ok: true };
});
}); });
it('renders with seeded vault-settings values', () => { it('renders with seeded vault-settings values', async () => {
const app = document.getElementById('app')!; const app = document.getElementById('app')!;
renderVaultSettings(app); renderVaultSettings(app);
expect(app.textContent).toContain('vault settings'); // Initial synchronous render paints the vault settings section headers
expect(app.querySelector('.section-header')?.textContent).toContain('VAULT SETTINGS');
expect(app.textContent).toContain('github.com'); expect(app.textContent).toContain('github.com');
expect(app.textContent).toContain('example.com'); expect(app.textContent).toContain('example.com');
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
expect(trashSel.value).toBe('days:30'); expect(trashSel.value).toBe('days:30');
const histSel = document.getElementById('history-retention') as HTMLSelectElement; const histSel = document.getElementById('history-retention') as HTMLSelectElement;
expect(histSel.value).toBe('forever'); expect(histSel.value).toBe('forever');
// After get_session_config resolves, SESSION row appears
await new Promise((r) => setTimeout(r, 10));
expect(app.textContent).toContain('SESSION');
expect(app.textContent).toContain('after inactivity');
}); });
it('renders origin acks sorted by recency (descending)', () => { it('renders origin acks sorted by recency (descending)', () => {
@@ -70,7 +81,7 @@ describe('settings-vault', () => {
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
trashSel.value = 'forever'; trashSel.value = 'forever';
trashSel.dispatchEvent(new Event('change', { bubbles: true })); trashSel.dispatchEvent(new Event('change', { bubbles: true }));
expect(saveBtn.disabled).toBe(false); expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false);
}); });
it('revoke button removes origin from pending and enables save', () => { it('revoke button removes origin from pending and enables save', () => {
@@ -95,4 +106,28 @@ describe('settings-vault', () => {
expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com'); expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com');
expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com'); expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com');
}); });
it('section headers render in correct order: VAULT SETTINGS, THIS DEVICE, ACTIONS', () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
const headers = Array.from(document.querySelectorAll('.section-header')).map((e) => e.textContent?.trim());
expect(headers[0]).toContain('VAULT SETTINGS');
expect(headers[1]).toContain('THIS DEVICE');
expect(headers[2]).toContain('ACTIONS');
});
it('subtitle shows "no changes" initially', () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
expect(app.querySelector('.settings-header__sub')?.textContent).toBe('no changes');
});
it('subtitle shows "unsaved · esc to cancel" after making a change', () => {
const app = document.getElementById('app')!;
renderVaultSettings(app);
const trashSel = document.getElementById('trash-retention') as HTMLSelectElement;
trashSel.value = 'forever';
trashSel.dispatchEvent(new Event('change', { bubbles: true }));
expect(document.querySelector('.settings-header__sub')?.textContent).toContain('unsaved');
});
}); });

View File

@@ -6,12 +6,15 @@ import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } f
import type { import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types'; } from '../../shared/types';
import type { SessionTimeoutConfig } from '../../shared/messages';
import { relativeTime } from '../../shared/relative-time'; import { relativeTime } from '../../shared/relative-time';
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
import { GLYPH_NEXT } from '../../shared/glyphs'; import { GLYPH_NEXT } from '../../shared/glyphs';
let pendingSettings: VaultSettings | null = null; let pendingSettings: VaultSettings | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let pendingSession: SessionTimeoutConfig | null = null;
let baseSession: SessionTimeoutConfig | null = null;
export function teardown(): void { export function teardown(): void {
closeGeneratorPanel(); closeGeneratorPanel();
@@ -20,6 +23,8 @@ export function teardown(): void {
activeKeyHandler = null; activeKeyHandler = null;
} }
pendingSettings = null; pendingSettings = null;
pendingSession = null;
baseSession = null;
} }
// --- Retention helpers --- // --- Retention helpers ---
@@ -77,20 +82,42 @@ export function renderVaultSettings(app: HTMLElement): void {
} }
pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings; pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings;
sendMessage({ type: 'get_session_config' }).then((resp) => {
// Guard against clobbering the user's in-flight edits if they tap a radio
// before the SW responds — tiny window but real.
if (resp.ok && !pendingSession) {
baseSession = (resp.data as { config: SessionTimeoutConfig }).config;
pendingSession = JSON.parse(JSON.stringify(baseSession)) as SessionTimeoutConfig;
rerender();
}
});
function rerender(): void { function rerender(): void {
if (!pendingSettings) return; if (!pendingSettings) return;
const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) const acksEntries = Object.entries(pendingSettings.autofill_origin_acks)
.sort(([, a], [, b]) => b - a); .sort(([, a], [, b]) => b - a);
const dirty: boolean = JSON.stringify(pendingSettings) !== JSON.stringify(base)
|| !!(baseSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession));
const subtitle = dirty ? 'unsaved · esc to cancel' : 'no changes';
const sessionMode = pendingSession?.mode ?? 'inactivity';
const sessionMinutes = pendingSession && pendingSession.mode === 'inactivity'
? pendingSession.minutes : 15;
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div class="settings-header"> <div class="settings-header">
<button class="btn" id="back-btn">← back</button> <button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">vault settings</h3> <h3 style="margin:0;">settings</h3>
<span class="muted settings-header__sub">${escapeHtml(subtitle)}</span>
</div> </div>
<div class="section-header">VAULT SETTINGS · synced</div>
<div class="settings-grid">
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">retention</div> <div class="settings-section__title">RETENTION</div>
<div class="settings-row"> <div class="settings-row">
<span class="settings-row__label">trash</span> <span class="settings-row__label">trash</span>
<select id="trash-retention"> <select id="trash-retention">
@@ -104,7 +131,7 @@ export function renderVaultSettings(app: HTMLElement): void {
</select> </select>
</div> </div>
<div class="settings-row"> <div class="settings-row">
<span class="settings-row__label">field history</span> <span class="settings-row__label">history</span>
<select id="history-retention"> <select id="history-retention">
<option value="forever">Forever</option> <option value="forever">Forever</option>
<option value="last_n:3">Last 3</option> <option value="last_n:3">Last 3</option>
@@ -118,28 +145,16 @@ export function renderVaultSettings(app: HTMLElement): void {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">generator</div> <div class="settings-section__title">GENERATOR</div>
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p> <p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
<button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨</button> <button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨ configure</button>
</div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">autofill origins</div> <div class="settings-section__title">ATTACHMENTS</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-section">
<div class="settings-section__title">attachments</div>
<div class="settings-row"> <div class="settings-row">
<span class="settings-row__label">max file size</span> <span class="settings-row__label">max size</span>
<select id="attachment-cap"> <select id="attachment-cap">
<option value="5242880">5 MB</option> <option value="5242880">5 MB</option>
<option value="10485760">10 MB</option> <option value="10485760">10 MB</option>
@@ -150,43 +165,61 @@ export function renderVaultSettings(app: HTMLElement): void {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div> <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="glyph-btn" data-danger title="revoke" data-revoke="${escapeHtml(host)}">⊘</button>
</div>
`).join('')}
</div>
<div class="section-header">THIS DEVICE · local</div>
<div class="settings-section">
<div class="settings-section__title">SESSION</div>
<div class="settings-row"> <div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button> <label><input type="radio" name="session-mode" value="every_time" ${sessionMode === 'every_time' ? 'checked' : ''}> lock every time</label>
</div>
<div class="settings-row">
<label><input type="radio" name="session-mode" value="inactivity" ${sessionMode === 'inactivity' ? 'checked' : ''}> after inactivity</label>
<select id="session-minutes" ${sessionMode !== 'inactivity' ? 'disabled' : ''}>
<option value="5">5 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">60 min</option>
</select>
</div> </div>
</div> </div>
<div class="section-header">ACTIONS</div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">import</div>
<div class="settings-row"> <div class="settings-row">
<button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button> <button class="btn" id="open-backup">Backup &amp; restore ${GLYPH_NEXT}</button>
<button class="btn" id="open-import">Import from… ${GLYPH_NEXT}</button>
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="settings-footer">
<button class="btn" id="discard-btn">discard</button> <button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button> <button class="btn btn-primary" id="save-btn" ${dirty ? '' : 'disabled'}>save changes</button>
</div> </div>
</div> </div>
`; `;
// Set current select values
(document.getElementById('trash-retention') as HTMLSelectElement).value = (document.getElementById('trash-retention') as HTMLSelectElement).value =
trashRetentionToValue(pendingSettings.trash_retention); trashRetentionToValue(pendingSettings.trash_retention);
(document.getElementById('history-retention') as HTMLSelectElement).value = (document.getElementById('history-retention') as HTMLSelectElement).value =
historyRetentionToValue(pendingSettings.field_history_retention); historyRetentionToValue(pendingSettings.field_history_retention);
const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760; const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760;
(document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue); (document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue);
(document.getElementById('session-minutes') as HTMLSelectElement).value = String(sessionMinutes);
wireHandlers(); 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 { function wireHandlers(): void {
@@ -198,13 +231,13 @@ export function renderVaultSettings(app: HTMLElement): void {
document.getElementById('trash-retention')?.addEventListener('change', (e) => { document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return; if (!pendingSettings) return;
pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
updateSaveEnabled(); rerender();
}); });
document.getElementById('history-retention')?.addEventListener('change', (e) => { document.getElementById('history-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return; if (!pendingSettings) return;
pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
updateSaveEnabled(); rerender();
}); });
document.getElementById('attachment-cap')?.addEventListener('change', (e) => { document.getElementById('attachment-cap')?.addEventListener('change', (e) => {
@@ -214,7 +247,7 @@ export function renderVaultSettings(app: HTMLElement): void {
...pendingSettings.attachment_caps, ...pendingSettings.attachment_caps,
per_attachment_max_bytes: bytes, per_attachment_max_bytes: bytes,
}; };
updateSaveEnabled(); rerender();
}); });
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => { document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
@@ -245,8 +278,18 @@ export function renderVaultSettings(app: HTMLElement): void {
document.getElementById('save-btn')?.addEventListener('click', async () => { document.getElementById('save-btn')?.addEventListener('click', async () => {
if (!pendingSettings) return; if (!pendingSettings) return;
const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings });
if (resp.ok) { if (!resp.ok) {
// Refresh cached state and navigate back. setState({ error: resp.error });
return;
}
if (pendingSession && JSON.stringify(pendingSession) !== JSON.stringify(baseSession)) {
const sessResp = await sendMessage({ type: 'update_session_config', config: pendingSession });
if (!sessResp.ok) {
setState({ error: sessResp.error });
return;
}
baseSession = JSON.parse(JSON.stringify(pendingSession)) as SessionTimeoutConfig;
}
const refreshed = await sendMessage({ type: 'get_vault_settings' }); const refreshed = await sendMessage({ type: 'get_vault_settings' });
if (refreshed.ok && refreshed.data) { if (refreshed.ok && refreshed.data) {
const vs = (refreshed.data as { settings: VaultSettings }).settings; const vs = (refreshed.data as { settings: VaultSettings }).settings;
@@ -255,8 +298,26 @@ export function renderVaultSettings(app: HTMLElement): void {
} }
} }
navigate('list'); navigate('list');
});
document.querySelectorAll<HTMLInputElement>('input[name="session-mode"]').forEach((el) => {
el.addEventListener('change', () => {
const mode = (document.querySelector<HTMLInputElement>('input[name="session-mode"]:checked')?.value ?? 'inactivity') as 'every_time' | 'inactivity';
if (mode === 'every_time') {
pendingSession = { mode: 'every_time' };
} else { } else {
setState({ error: resp.error }); const mins = Number((document.getElementById('session-minutes') as HTMLSelectElement).value);
pendingSession = { mode: 'inactivity', minutes: mins };
}
rerender();
});
});
document.getElementById('session-minutes')?.addEventListener('change', (e) => {
const mins = Number((e.target as HTMLSelectElement).value);
if (pendingSession?.mode === 'inactivity') {
pendingSession = { mode: 'inactivity', minutes: mins };
rerender();
} }
}); });
} }

View File

@@ -1812,3 +1812,18 @@ textarea {
.setting-card__status { font-size: 13px; margin-bottom: 8px; } .setting-card__status { font-size: 13px; margin-bottom: 8px; }
.setting-card__actions { display: flex; gap: 8px; } .setting-card__actions { display: flex; gap: 8px; }
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 720px) {
.settings-grid { grid-template-columns: 1fr; }
}
.settings-header__sub {
margin-left: auto;
font-size: 11px;
}

View File

@@ -2180,3 +2180,18 @@ textarea {
word-break: break-all; /* wraps to two lines in popup (~360px) */ word-break: break-all; /* wraps to two lines in popup (~360px) */
line-height: 1.4; line-height: 1.4;
} }
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 720px) {
.settings-grid { grid-template-columns: 1fr; }
}
.settings-header__sub {
margin-left: auto;
font-size: 11px;
}