|
|
|
|
@@ -6,12 +6,15 @@ import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } f
|
|
|
|
|
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 { 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 {
|
|
|
|
|
closeGeneratorPanel();
|
|
|
|
|
@@ -20,6 +23,8 @@ export function teardown(): void {
|
|
|
|
|
activeKeyHandler = null;
|
|
|
|
|
}
|
|
|
|
|
pendingSettings = null;
|
|
|
|
|
pendingSession = null;
|
|
|
|
|
baseSession = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Retention helpers ---
|
|
|
|
|
@@ -77,20 +82,42 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
}
|
|
|
|
|
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 = `
|
|
|
|
|
<div class="pad">
|
|
|
|
|
<div class="settings-header">
|
|
|
|
|
<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 class="section-header">VAULT SETTINGS · synced</div>
|
|
|
|
|
|
|
|
|
|
<div class="settings-grid">
|
|
|
|
|
<div class="settings-section">
|
|
|
|
|
<div class="settings-section__title">retention</div>
|
|
|
|
|
<div class="settings-section__title">RETENTION</div>
|
|
|
|
|
<div class="settings-row">
|
|
|
|
|
<span class="settings-row__label">trash</span>
|
|
|
|
|
<select id="trash-retention">
|
|
|
|
|
@@ -104,7 +131,7 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="settings-row">
|
|
|
|
|
<span class="settings-row__label">field history</span>
|
|
|
|
|
<span class="settings-row__label">history</span>
|
|
|
|
|
<select id="history-retention">
|
|
|
|
|
<option value="forever">Forever</option>
|
|
|
|
|
<option value="last_n:3">Last 3</option>
|
|
|
|
|
@@ -118,28 +145,16 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
<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 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-section">
|
|
|
|
|
<div class="settings-section__title">attachments</div>
|
|
|
|
|
<div class="settings-section__title">ATTACHMENTS</div>
|
|
|
|
|
<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">
|
|
|
|
|
<option value="5242880">5 MB</option>
|
|
|
|
|
<option value="10485760">10 MB</option>
|
|
|
|
|
@@ -150,43 +165,61 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="settings-section">
|
|
|
|
|
<div class="settings-section__title">backup & 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">
|
|
|
|
|
<button class="btn" id="open-backup">Backup & 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 class="section-header">ACTIONS</div>
|
|
|
|
|
|
|
|
|
|
<div class="settings-section">
|
|
|
|
|
<div class="settings-section__title">import</div>
|
|
|
|
|
<div class="settings-row">
|
|
|
|
|
<button class="btn" id="open-import">LastPass CSV ${GLYPH_NEXT}</button>
|
|
|
|
|
<button class="btn" id="open-backup">Backup & restore ${GLYPH_NEXT}</button>
|
|
|
|
|
<button class="btn" id="open-import">Import from… ${GLYPH_NEXT}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</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>
|
|
|
|
|
<button class="btn btn-primary" id="save-btn" ${dirty ? '' : '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);
|
|
|
|
|
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();
|
|
|
|
|
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 {
|
|
|
|
|
@@ -198,13 +231,13 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
|
|
|
|
if (!pendingSettings) return;
|
|
|
|
|
pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
|
|
|
|
|
updateSaveEnabled();
|
|
|
|
|
rerender();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('history-retention')?.addEventListener('change', (e) => {
|
|
|
|
|
if (!pendingSettings) return;
|
|
|
|
|
pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
|
|
|
|
|
updateSaveEnabled();
|
|
|
|
|
rerender();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('attachment-cap')?.addEventListener('change', (e) => {
|
|
|
|
|
@@ -214,7 +247,7 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
...pendingSettings.attachment_caps,
|
|
|
|
|
per_attachment_max_bytes: bytes,
|
|
|
|
|
};
|
|
|
|
|
updateSaveEnabled();
|
|
|
|
|
rerender();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {
|
|
|
|
|
@@ -245,8 +278,18 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
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.
|
|
|
|
|
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;
|
|
|
|
|
@@ -255,8 +298,26 @@ export function renderVaultSettings(app: HTMLElement): void {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|