feat(ext/popup): vault-settings screen (retention + generator + origin-ack revoke)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -649,3 +649,45 @@ textarea {
|
|||||||
gap: 6px; margin-top: 10px;
|
gap: 6px; margin-top: 10px;
|
||||||
}
|
}
|
||||||
.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user