/// 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, openVaultTab } from '../../shared/state'; import type { VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, } from '../../shared/types'; import type { SessionTimeoutConfig } from '../../shared/messages'; import { relativeTime } from '../../shared/relative-time'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; import { teardownSettingsCommon } from './settings'; import { GLYPH_NEXT } from '../../shared/glyphs'; let pendingSettings: VaultSettings | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let pendingSession: SessionTimeoutConfig | null = null; let baseSession: SessionTimeoutConfig | null = null; export function teardown(): void { activeKeyHandler = teardownSettingsCommon(activeKeyHandler); pendingSettings = null; pendingSession = null; baseSession = 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}`; } // --- Render --- export function renderVaultSettings(app: HTMLElement): void { const state = getState(); const base = state.vaultSettings; if (!base) { app.innerHTML = `

Vault settings not loaded yet.

`; return; } pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings; 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 { if (!pendingSettings) return; const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) .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 = `

settings

${escapeHtml(subtitle)}
VAULT SETTINGS · synced
RETENTION
trash
history
GENERATOR

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

ATTACHMENTS
max size
AUTOFILL ORIGINS
${acksEntries.length === 0 ? `

No origins acknowledged yet.

` : acksEntries.map(([host, ts]) => `
${escapeHtml(host)} ${escapeHtml(relativeTime(ts))}
`).join('')}
THIS DEVICE · local
SESSION
ACTIONS
`; (document.getElementById('trash-retention') as HTMLSelectElement).value = trashRetentionToValue(pendingSettings.trash_retention); (document.getElementById('history-retention') as HTMLSelectElement).value = historyRetentionToValue(pendingSettings.field_history_retention); const capValue = pendingSettings.attachment_caps?.per_attachment_max_bytes ?? 10485760; (document.getElementById('attachment-cap') as HTMLSelectElement).value = String(capValue); (document.getElementById('session-minutes') as HTMLSelectElement).value = String(sessionMinutes); wireHandlers(); } function wireHandlers(): void { document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup')); document.getElementById('open-import')?.addEventListener('click', () => openVaultTab('import')); document.getElementById('trash-retention')?.addEventListener('change', (e) => { if (!pendingSettings) return; pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); rerender(); }); document.getElementById('history-retention')?.addEventListener('change', (e) => { if (!pendingSettings) return; pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); rerender(); }); document.getElementById('attachment-cap')?.addEventListener('change', (e) => { if (!pendingSettings) return; const bytes = Number((e.target as HTMLSelectElement).value); pendingSettings.attachment_caps = { ...pendingSettings.attachment_caps, per_attachment_max_bytes: bytes, }; rerender(); }); document.querySelectorAll('[data-revoke]').forEach((btn) => { btn.addEventListener('click', () => { if (!pendingSettings) return; const host = btn.dataset.revoke ?? ''; delete pendingSettings.autofill_origin_acks[host]; rerender(); }); }); document.getElementById('configure-gen')?.addEventListener('click', (e) => { const trigger = e.currentTarget as HTMLElement; if (isGeneratorPanelOpen()) { closeGeneratorPanel(); return; } const generatorSection = trigger.closest('.settings-section') as HTMLElement | null; if (!generatorSection || pendingSettings === null) return; openGeneratorPanel({ parent: generatorSection, trigger, initial: pendingSettings.generator_defaults, context: 'configure-defaults', }); }); document.getElementById('save-btn')?.addEventListener('click', async () => { if (!pendingSettings) return; const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); if (!resp.ok) { 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' }); if (refreshed.ok && refreshed.data) { const vs = (refreshed.data as { settings: VaultSettings }).settings; if (vs) { setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); } } navigate('list'); }); document.querySelectorAll('input[name="session-mode"]').forEach((el) => { el.addEventListener('change', () => { const mode = (document.querySelector('input[name="session-mode"]:checked')?.value ?? 'inactivity') as 'every_time' | 'inactivity'; if (mode === 'every_time') { pendingSession = { mode: 'every_time' }; } else { 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(); } }); } 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); }