Compare commits
4 Commits
feature/v0
...
ddfb95d683
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddfb95d683 | ||
|
|
7df76c692a | ||
|
|
b4d253c60b | ||
|
|
c16adc4335 |
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.stubGlobal('chrome', {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: vi.fn((_keys: unknown, cb: (r: Record<string, unknown>) => void) => cb({})),
|
||||||
|
set: vi.fn((_data: unknown, cb?: () => void) => cb?.()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
import * as settingsMod from '../settings';
|
||||||
|
|
||||||
|
describe('settings module contract', () => {
|
||||||
|
it('exports renderSettings as a function', () => {
|
||||||
|
expect(typeof settingsMod.renderSettings).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports teardownSettings as a function', () => {
|
||||||
|
expect(typeof settingsMod.teardownSettings).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,329 +1,17 @@
|
|||||||
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
|
// extension/src/popup/components/settings-security.ts
|
||||||
///
|
// Stub — real implementation provided by Stream C (DEV-C).
|
||||||
/// Exported contract:
|
|
||||||
/// renderSecuritySection(container, sessionHandle): renders into `container`
|
|
||||||
/// teardownSecuritySection(): removes any open QR modal
|
|
||||||
|
|
||||||
import { sendMessage, escapeHtml } from '../../shared/state';
|
|
||||||
import type { Device } from '../../shared/types';
|
|
||||||
|
|
||||||
// --- Relative time helper ---
|
|
||||||
|
|
||||||
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`;
|
|
||||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
|
||||||
return `${Math.floor(diff / 2592000)}mo ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Modal helpers ---
|
|
||||||
|
|
||||||
const MODAL_ID = 'relicario-qr-modal';
|
|
||||||
|
|
||||||
function removeModal(): void {
|
|
||||||
document.getElementById(MODAL_ID)?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showQrModal(svgContent: string): void {
|
|
||||||
removeModal();
|
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.id = MODAL_ID;
|
|
||||||
overlay.style.cssText = [
|
|
||||||
'position:fixed', 'inset:0', 'z-index:9999',
|
|
||||||
'background:rgba(0,0,0,0.85)',
|
|
||||||
'display:flex', 'flex-direction:column',
|
|
||||||
'align-items:center', 'justify-content:center',
|
|
||||||
'padding:16px', 'box-sizing:border-box',
|
|
||||||
].join(';');
|
|
||||||
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div style="
|
|
||||||
background:#161b22; border:1px solid #30363d; border-radius:8px;
|
|
||||||
padding:16px; max-width:340px; width:100%; text-align:center;
|
|
||||||
">
|
|
||||||
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
|
|
||||||
Recovery QR
|
|
||||||
</div>
|
|
||||||
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
|
|
||||||
Print or store this QR. It encodes your reference image secret,
|
|
||||||
protected by your passphrase.
|
|
||||||
</div>
|
|
||||||
<div id="relicario-qr-svg" style="
|
|
||||||
background:#fff; border-radius:4px; padding:8px;
|
|
||||||
display:inline-block; max-width:280px; width:100%;
|
|
||||||
">
|
|
||||||
${svgContent}
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
|
|
||||||
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
|
|
||||||
Print
|
|
||||||
</button>
|
|
||||||
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
|
|
||||||
|
|
||||||
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
|
|
||||||
const win = window.open('', '_blank', 'width=400,height=500');
|
|
||||||
if (!win) return;
|
|
||||||
win.document.write(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html><head><title>Recovery QR</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; display: flex; flex-direction: column; align-items: center;
|
|
||||||
font-family: sans-serif; padding: 24px; }
|
|
||||||
h2 { font-size: 16px; margin-bottom: 8px; }
|
|
||||||
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
|
|
||||||
svg { max-width: 280px; width: 100%; }
|
|
||||||
</style></head><body>
|
|
||||||
<h2>Relicario Recovery QR</h2>
|
|
||||||
<p>Scan with the Relicario app to recover your reference image secret.<br>
|
|
||||||
Keep this page in a safe physical location.</p>
|
|
||||||
${svgContent}
|
|
||||||
<script>window.onload = () => { window.print(); window.close(); }<\/script>
|
|
||||||
</body></html>
|
|
||||||
`);
|
|
||||||
win.document.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on backdrop click
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay) removeModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main render ---
|
|
||||||
|
|
||||||
export async function renderSecuritySection(
|
export async function renderSecuritySection(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
sessionHandle: number | null,
|
_sessionHandle: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Read timestamp from device-local storage (never the QR payload itself)
|
|
||||||
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
|
|
||||||
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
|
|
||||||
|
|
||||||
const isUnlocked = sessionHandle !== null;
|
|
||||||
|
|
||||||
// --- QR status section ---
|
|
||||||
let qrStatusHtml: string;
|
|
||||||
if (generatedAt === null) {
|
|
||||||
qrStatusHtml = `
|
|
||||||
<div style="
|
|
||||||
display:flex; align-items:flex-start; gap:10px;
|
|
||||||
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
|
|
||||||
padding:10px; margin-bottom:12px;
|
|
||||||
">
|
|
||||||
<span style="font-size:16px;">⚠</span>
|
|
||||||
<div style="flex:1; font-size:12px;">
|
|
||||||
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
|
|
||||||
No recovery QR generated
|
|
||||||
</div>
|
|
||||||
<div style="color:#8b949e;">
|
|
||||||
If you lose access to your reference image, you will be locked out permanently.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
id="sec-generate-qr"
|
|
||||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
|
||||||
style="width:100%; font-size:12px; margin-bottom:4px;"
|
|
||||||
>
|
|
||||||
Generate recovery QR…
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
qrStatusHtml = `
|
|
||||||
<div style="
|
|
||||||
display:flex; align-items:flex-start; gap:10px;
|
|
||||||
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
|
|
||||||
padding:10px; margin-bottom:12px;
|
|
||||||
">
|
|
||||||
<span style="font-size:16px;">✓</span>
|
|
||||||
<div style="flex:1; font-size:12px;">
|
|
||||||
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
|
|
||||||
Recovery QR set up
|
|
||||||
</div>
|
|
||||||
<div style="color:#8b949e;">
|
|
||||||
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:8px; margin-bottom:4px;">
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
id="sec-show-qr"
|
|
||||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
|
||||||
style="flex:1; font-size:12px;"
|
|
||||||
>
|
|
||||||
Show / print QR…
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
id="sec-regenerate-qr"
|
|
||||||
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
|
|
||||||
style="flex:1; font-size:12px;"
|
|
||||||
>
|
|
||||||
Regenerate…
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Devices section ---
|
|
||||||
const devicesResp = await sendMessage({ type: 'list_devices' });
|
|
||||||
let devicesHtml: string;
|
|
||||||
if (!devicesResp.ok) {
|
|
||||||
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
|
|
||||||
} else {
|
|
||||||
const devices = (devicesResp.data as { devices: Device[] }).devices;
|
|
||||||
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
|
|
||||||
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
|
|
||||||
|
|
||||||
if (devices.length === 0) {
|
|
||||||
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
|
|
||||||
} else {
|
|
||||||
devicesHtml = devices.map((d) => {
|
|
||||||
const isCurrent = d.name === currentDeviceName;
|
|
||||||
return `
|
|
||||||
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
|
|
||||||
<div style="flex:1; min-width:0;">
|
|
||||||
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
|
||||||
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
|
|
||||||
</div>
|
|
||||||
${isCurrent ? '' : `
|
|
||||||
<button
|
|
||||||
class="btn sec-revoke-btn"
|
|
||||||
data-device-name="${escapeHtml(d.name)}"
|
|
||||||
style="font-size:11px; margin-left:8px; flex-shrink:0;"
|
|
||||||
>revoke</button>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Assemble ---
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="settings-section" style="margin-top:0;">
|
<div class="settings-section-placeholder">
|
||||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
<span class="muted">Security settings — loading…</span>
|
||||||
Recovery QR
|
|
||||||
</div>
|
|
||||||
${qrStatusHtml}
|
|
||||||
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section" style="margin-top:16px;">
|
|
||||||
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
|
|
||||||
Trusted Devices
|
|
||||||
</div>
|
|
||||||
<div id="sec-devices-list">
|
|
||||||
${devicesHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// --- Wire handlers ---
|
|
||||||
|
|
||||||
const setQrError = (msg: string): void => {
|
|
||||||
const el = document.getElementById('sec-qr-error');
|
|
||||||
if (el) el.textContent = msg;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function doGenerateQr(isRegen: boolean): Promise<void> {
|
|
||||||
const passphrase = prompt(
|
|
||||||
isRegen
|
|
||||||
? 'Enter your vault passphrase to regenerate the recovery QR:'
|
|
||||||
: 'Enter your vault passphrase to generate the recovery QR:',
|
|
||||||
);
|
|
||||||
if (!passphrase) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
|
||||||
|
|
||||||
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
|
||||||
if (!resp.ok) {
|
|
||||||
setQrError(`Failed: ${resp.error}`);
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svg = (resp.data as { svg: string }).svg;
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
// Store only the timestamp, NEVER the QR payload
|
|
||||||
await chrome.storage.local.set({ recovery_qr_generated_at: now });
|
|
||||||
|
|
||||||
showQrModal(svg);
|
|
||||||
|
|
||||||
// Re-render to reflect new state (timestamp now exists)
|
|
||||||
await renderSecuritySection(container, sessionHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
|
|
||||||
void doGenerateQr(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
|
|
||||||
void doGenerateQr(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
|
|
||||||
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
|
|
||||||
if (!passphrase) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
|
||||||
|
|
||||||
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
|
|
||||||
if (!resp.ok) {
|
|
||||||
setQrError(`Failed: ${resp.error}`);
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
|
|
||||||
const svg = (resp.data as { svg: string }).svg;
|
|
||||||
showQrModal(svg);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Revoke buttons
|
|
||||||
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const name = btn.dataset.deviceName;
|
|
||||||
if (!name) return;
|
|
||||||
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '…';
|
|
||||||
|
|
||||||
const result = await sendMessage({ type: 'revoke_device', name });
|
|
||||||
if (result.ok) {
|
|
||||||
await sendMessage({ type: 'sync' });
|
|
||||||
// Re-render to refresh device list
|
|
||||||
await renderSecuritySection(container, sessionHandle);
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'revoke';
|
|
||||||
setQrError(`Revoke failed: ${result.error}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function teardownSecuritySection(): void {
|
export function teardownSecuritySection(): void {
|
||||||
removeModal();
|
// no-op in stub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,111 @@
|
|||||||
/// Settings view — capture toggle, prompt style, and blacklist management.
|
import { sendMessage, escapeHtml, openVaultTab } from '../../shared/state';
|
||||||
|
import type { VaultSettings, DeviceSettings, TrashRetention, HistoryRetention } from '../../shared/types';
|
||||||
import { sendMessage, navigate, escapeHtml } from '../../shared/state';
|
import type { ColorScheme } from '../../shared/color-scheme';
|
||||||
import type { DeviceSettings } from '../../shared/types';
|
|
||||||
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SYNC } from '../../shared/glyphs';
|
|
||||||
import {
|
import {
|
||||||
loadColorScheme, saveColorScheme, resetColorScheme,
|
loadColorScheme, saveColorScheme, resetColorScheme,
|
||||||
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
|
||||||
} from '../../shared/color-scheme';
|
} from '../../shared/color-scheme';
|
||||||
import { colorizePassword } from '../../shared/password-coloring';
|
import { colorizePassword } from '../../shared/password-coloring';
|
||||||
|
import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
|
||||||
|
import { renderSecuritySection, teardownSecuritySection } from './settings-security';
|
||||||
|
|
||||||
export async function renderSettings(app: HTMLElement): Promise<void> {
|
type SettingsSection =
|
||||||
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
|
| 'autofill'
|
||||||
|
| 'display'
|
||||||
|
| 'security'
|
||||||
|
| 'generator'
|
||||||
|
| 'retention'
|
||||||
|
| 'backup'
|
||||||
|
| 'import';
|
||||||
|
|
||||||
// Load settings and blacklist in parallel
|
const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [
|
||||||
|
{ id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' },
|
||||||
|
{ id: 'display', icon: '◈', label: 'Display', group: 'device' },
|
||||||
|
{ id: 'security', icon: '◉', label: 'Security', group: 'vault' },
|
||||||
|
{ id: 'generator', icon: '↻', label: 'Generator', group: 'vault' },
|
||||||
|
{ id: 'retention', icon: '▦', label: 'Retention', group: 'vault' },
|
||||||
|
{ id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' },
|
||||||
|
{ id: 'import', icon: '≡', label: 'Import', group: 'vault' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let activeSection: SettingsSection = 'autofill';
|
||||||
|
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
let pendingVaultSettings: VaultSettings | null = null;
|
||||||
|
let sessionHandle: number | null = null;
|
||||||
|
|
||||||
|
export async function renderSettings(container: HTMLElement): Promise<void> {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="settings-layout">
|
||||||
|
<nav class="settings-nav" id="settings-nav">
|
||||||
|
<div class="settings-nav__group-label">Device</div>
|
||||||
|
${NAV_ITEMS.filter(n => n.group === 'device').map(navItemHtml).join('')}
|
||||||
|
<div class="settings-nav__group-label">Vault</div>
|
||||||
|
${NAV_ITEMS.filter(n => n.group === 'vault').map(navItemHtml).join('')}
|
||||||
|
</nav>
|
||||||
|
<div class="settings-content" id="settings-content"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const unlockedResp = await sendMessage({ type: 'is_unlocked' });
|
||||||
|
sessionHandle = (unlockedResp.ok && unlockedResp.data && (unlockedResp.data as { unlocked: boolean }).unlocked) ? 1 : null;
|
||||||
|
|
||||||
|
wireNav();
|
||||||
|
await renderSection(activeSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardownSettings(): void {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
teardownSecuritySection();
|
||||||
|
if (activeKeyHandler) {
|
||||||
|
document.removeEventListener('keydown', activeKeyHandler);
|
||||||
|
activeKeyHandler = null;
|
||||||
|
}
|
||||||
|
pendingVaultSettings = null;
|
||||||
|
sessionHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navItemHtml(item: (typeof NAV_ITEMS)[0]): string {
|
||||||
|
const active = item.id === activeSection ? ' settings-nav__item--active' : '';
|
||||||
|
return `
|
||||||
|
<button class="settings-nav__item${active}" data-section="${item.id}">
|
||||||
|
<span class="settings-nav__icon" aria-hidden="true">${item.icon}</span>
|
||||||
|
<span class="settings-nav__label">${escapeHtml(item.label)}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireNav(): void {
|
||||||
|
document.getElementById('settings-nav')?.querySelectorAll<HTMLButtonElement>('[data-section]')
|
||||||
|
.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
teardownSecuritySection();
|
||||||
|
closeGeneratorPanel();
|
||||||
|
activeSection = btn.dataset.section as SettingsSection;
|
||||||
|
document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active'));
|
||||||
|
btn.classList.add('settings-nav__item--active');
|
||||||
|
await renderSection(activeSection);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSection(section: SettingsSection): Promise<void> {
|
||||||
|
const content = document.getElementById('settings-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
switch (section) {
|
||||||
|
case 'autofill': return renderAutofillSection(content);
|
||||||
|
case 'display': return renderDisplaySection(content);
|
||||||
|
case 'security': return renderSecuritySection(content, sessionHandle);
|
||||||
|
case 'generator': return renderGeneratorSection(content);
|
||||||
|
case 'retention': return renderRetentionSection(content);
|
||||||
|
case 'backup': return renderBackupSection(content);
|
||||||
|
case 'import': return renderImportSection(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Section stubs (filled in by Tasks 3-9) ---
|
||||||
|
|
||||||
|
async function renderAutofillSection(content: HTMLElement): Promise<void> {
|
||||||
const [settingsResp, blacklistResp] = await Promise.all([
|
const [settingsResp, blacklistResp] = await Promise.all([
|
||||||
sendMessage({ type: 'get_settings' }),
|
sendMessage({ type: 'get_settings' }),
|
||||||
sendMessage({ type: 'get_blacklist' }),
|
sendMessage({ type: 'get_blacklist' }),
|
||||||
@@ -26,171 +119,314 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
|
|||||||
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
? (blacklistResp.data as { blacklist: string[] }).blacklist
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const blacklistHtml = blacklist.length > 0
|
content.innerHTML = `
|
||||||
? blacklist.map((h) => `
|
<h3 class="settings-section-title">Capture</h3>
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; padding:4px 0; border-bottom:1px solid #21262d;">
|
<div class="setting-row">
|
||||||
<span style="font-size:12px; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(h)}</span>
|
<div class="setting-row__info">
|
||||||
<button class="relicario-remove-bl" data-hostname="${escapeHtml(h)}" style="
|
<div class="setting-row__title">Auto-detect logins</div>
|
||||||
background:transparent; color:#ab2b20; border:none; cursor:pointer;
|
<div class="setting-row__desc">Show a prompt when a login form is detected.</div>
|
||||||
font-size:11px; padding:2px 6px;
|
|
||||||
">remove</button>
|
|
||||||
</div>
|
|
||||||
`).join('')
|
|
||||||
: '<p class="muted" style="font-size:12px;">no blacklisted sites</p>';
|
|
||||||
|
|
||||||
app.innerHTML = `
|
|
||||||
<div class="pad" style="padding-top:12px;">
|
|
||||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
|
||||||
<button id="settings-back" class="btn" style="font-size:11px; margin-right:8px;">←</button>
|
|
||||||
<span style="font-size:14px; font-weight:600;">settings</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
<div style="margin-bottom:16px;">
|
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
||||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
|
|
||||||
<input type="checkbox" id="capture-enabled" ${settings.captureEnabled ? 'checked' : ''}>
|
|
||||||
auto-detect logins
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style="margin-bottom:16px;">
|
<div class="setting-row">
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">prompt style</div>
|
<div class="setting-row__info">
|
||||||
<div style="display:flex; gap:8px;">
|
<div class="setting-row__title">Prompt style</div>
|
||||||
<button id="style-bar" class="btn" style="font-size:11px; ${settings.captureStyle === 'bar' ? 'background:#7c5719; color:#fff;' : ''}">bar</button>
|
<div class="setting-row__desc">How to prompt when a login is detected.</div>
|
||||||
<button id="style-toast" class="btn" style="font-size:11px; ${settings.captureStyle === 'toast' ? 'background:#7c5719; color:#fff;' : ''}">toast</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row__control" style="display:flex; gap:6px;">
|
||||||
<div style="margin-bottom:16px;">
|
<button class="btn ${settings.captureStyle === 'bar' ? 'btn-active' : ''}" id="style-bar" style="font-size:11px;">bar</button>
|
||||||
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
|
<button class="btn ${settings.captureStyle === 'toast' ? 'btn-active' : ''}" id="style-toast" style="font-size:11px;">toast</button>
|
||||||
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
|
|
||||||
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">${GLYPH_SYNC} Sync now</button>
|
|
||||||
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:16px;" id="display-section-container">
|
<h3 class="settings-section-title" style="margin-top:20px;">Blocked sites</h3>
|
||||||
</div>
|
<div id="blacklist-container">
|
||||||
|
${blacklist.length > 0
|
||||||
<div>
|
? blacklist.map((h) => `
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
|
<div class="setting-row">
|
||||||
<div id="blacklist-container">
|
<div class="setting-row__info">
|
||||||
${blacklistHtml}
|
<div class="setting-row__title">${escapeHtml(h)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="btn remove-bl" data-hostname="${escapeHtml(h)}" style="font-size:11px;">remove</button>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
: '<p class="muted" style="font-size:12px; padding:8px 0;">No blocked sites.</p>'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Back button
|
|
||||||
document.getElementById('settings-back')?.addEventListener('click', () => {
|
|
||||||
navigate('locked');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigation buttons
|
|
||||||
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
|
|
||||||
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices'));
|
|
||||||
|
|
||||||
// Sync now button
|
|
||||||
document.getElementById('sync-now-btn')?.addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null;
|
|
||||||
const status = document.getElementById('sync-status');
|
|
||||||
if (!btn || !status) return;
|
|
||||||
btn.disabled = true;
|
|
||||||
status.textContent = 'syncing...';
|
|
||||||
const result = await sendMessage({ type: 'sync' });
|
|
||||||
btn.disabled = false;
|
|
||||||
status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Capture enabled toggle
|
|
||||||
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
|
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
|
||||||
const checked = (e.target as HTMLInputElement).checked;
|
const enabled = (e.target as HTMLInputElement).checked;
|
||||||
await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } });
|
await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Style buttons
|
|
||||||
document.getElementById('style-bar')?.addEventListener('click', async () => {
|
document.getElementById('style-bar')?.addEventListener('click', async () => {
|
||||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
|
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } });
|
||||||
renderSettings(app);
|
renderAutofillSection(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('style-toast')?.addEventListener('click', async () => {
|
document.getElementById('style-toast')?.addEventListener('click', async () => {
|
||||||
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
|
await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } });
|
||||||
renderSettings(app);
|
renderAutofillSection(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Blacklist remove buttons
|
content.querySelectorAll<HTMLButtonElement>('.remove-bl').forEach((btn) => {
|
||||||
document.querySelectorAll('.relicario-remove-bl').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const hostname = (btn as HTMLElement).dataset.hostname;
|
const host = btn.dataset.hostname;
|
||||||
if (hostname) {
|
if (!host) return;
|
||||||
await sendMessage({ type: 'remove_blacklist', hostname });
|
await sendMessage({ type: 'remove_blacklist', hostname: host });
|
||||||
renderSettings(app);
|
renderAutofillSection(content);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render Display section after the rest of the DOM is ready
|
|
||||||
await renderDisplaySection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
|
async function renderDisplaySection(content: HTMLElement): Promise<void> {
|
||||||
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
|
|
||||||
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
|
|
||||||
swatch.innerHTML = '';
|
|
||||||
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderDisplaySection(): Promise<void> {
|
|
||||||
// The Display section container must be present in the DOM before we call this
|
|
||||||
const container = document.getElementById('display-section-container');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const scheme = await loadColorScheme();
|
const scheme = await loadColorScheme();
|
||||||
|
|
||||||
container.innerHTML = `
|
content.innerHTML = `
|
||||||
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
|
<h3 class="settings-section-title">Password coloring</h3>
|
||||||
<div style="margin-bottom:8px;">
|
<div class="setting-row">
|
||||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
<div class="setting-row__info">
|
||||||
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
|
<div class="setting-row__title">Digit color</div>
|
||||||
digit color
|
</div>
|
||||||
</label>
|
<div class="setting-row__control">
|
||||||
|
<input type="color" id="digit-color" value="${escapeHtml(scheme.digit_color)}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom:8px;">
|
<div class="setting-row">
|
||||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
|
<div class="setting-row__info">
|
||||||
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
<div class="setting-row__title">Symbol color</div>
|
||||||
symbol color
|
</div>
|
||||||
</label>
|
<div class="setting-row__control">
|
||||||
|
<input type="color" id="symbol-color" value="${escapeHtml(scheme.symbol_color)}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="display-swatch" class="color-preview-swatch"></div>
|
<div class="setting-row">
|
||||||
<div style="margin-top:8px;">
|
<div class="setting-row__info">
|
||||||
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
|
<div class="setting-row__title">Preview</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
|
<span id="color-preview" style="font-family:monospace; font-size:13px;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="btn" id="reset-colors" style="font-size:11px;">Reset defaults</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
|
function refreshPreview(s: ColorScheme): void {
|
||||||
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
|
const preview = document.getElementById('color-preview');
|
||||||
const swatch = document.getElementById('display-swatch') as HTMLElement;
|
if (!preview) return;
|
||||||
|
preview.style.setProperty('--relicario-pwd-digit-color', s.digit_color);
|
||||||
// Render initial swatch
|
preview.style.setProperty('--relicario-pwd-symbol-color', s.symbol_color);
|
||||||
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
|
preview.innerHTML = '';
|
||||||
|
preview.appendChild(colorizePassword('Abc123!@#'));
|
||||||
async function onColorChange(): Promise<void> {
|
|
||||||
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
|
|
||||||
await saveColorScheme(newScheme);
|
|
||||||
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
digitInput.addEventListener('change', () => void onColorChange());
|
refreshPreview(scheme);
|
||||||
symbolInput.addEventListener('change', () => void onColorChange());
|
|
||||||
|
|
||||||
document.getElementById('display-reset')?.addEventListener('click', async () => {
|
document.getElementById('digit-color')?.addEventListener('change', async (e) => {
|
||||||
|
const color = (e.target as HTMLInputElement).value;
|
||||||
|
const current = await loadColorScheme();
|
||||||
|
await saveColorScheme({ ...current, digit_color: color });
|
||||||
|
refreshPreview({ ...current, digit_color: color });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('symbol-color')?.addEventListener('change', async (e) => {
|
||||||
|
const color = (e.target as HTMLInputElement).value;
|
||||||
|
const current = await loadColorScheme();
|
||||||
|
await saveColorScheme({ ...current, symbol_color: color });
|
||||||
|
refreshPreview({ ...current, symbol_color: color });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reset-colors')?.addEventListener('click', async () => {
|
||||||
await resetColorScheme();
|
await resetColorScheme();
|
||||||
digitInput.value = DEFAULT_DIGIT_COLOR;
|
renderDisplaySection(content);
|
||||||
symbolInput.value = DEFAULT_SYMBOL_COLOR;
|
|
||||||
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEV-B interface contract stub — will be replaced with real teardown logic at merge time
|
async function renderGeneratorSection(content: HTMLElement): Promise<void> {
|
||||||
export function teardownSettings(): void {
|
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
|
||||||
// no-op stub
|
const resp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errorMsg = (resp as { ok: false; error: string }).error;
|
||||||
|
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(errorMsg)}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = (resp.data as { settings: VaultSettings }).settings;
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<h3 class="settings-section-title">Generator defaults</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row__info">
|
||||||
|
<div class="setting-row__title">Configure generator</div>
|
||||||
|
<div class="setting-row__desc">Password length, character classes, passphrase word count.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
|
<button class="btn" id="open-generator-panel" style="font-size:11px;">Configure ▸</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('open-generator-panel')?.addEventListener('click', (e) => {
|
||||||
|
const trigger = e.currentTarget as HTMLElement;
|
||||||
|
if (isGeneratorPanelOpen()) {
|
||||||
|
closeGeneratorPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openGeneratorPanel({
|
||||||
|
parent: content,
|
||||||
|
trigger,
|
||||||
|
initial: settings.generator_defaults,
|
||||||
|
context: 'configure-defaults',
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderRetentionSection(content: HTMLElement): Promise<void> {
|
||||||
|
content.innerHTML = '<p class="muted" style="padding:20px;font-size:12px;">Loading…</p>';
|
||||||
|
const resp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
content.innerHTML = `<p class="muted" style="padding:20px;">Failed to load: ${escapeHtml(resp.error ?? 'unknown')}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = (resp.data as { settings: VaultSettings }).settings;
|
||||||
|
pendingVaultSettings = { ...settings };
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<h3 class="settings-section-title">Trash retention</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row__info">
|
||||||
|
<div class="setting-row__title">Keep deleted items for</div>
|
||||||
|
<div class="setting-row__desc">Items in trash older than this are permanently deleted on the next sync.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
|
<select id="trash-retention" style="font-size:12px;">
|
||||||
|
<option value="days:7">7 days</option>
|
||||||
|
<option value="days:30">30 days</option>
|
||||||
|
<option value="days:90">90 days</option>
|
||||||
|
<option value="forever">Forever</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="settings-section-title" style="margin-top:20px;">Field history retention</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row__info">
|
||||||
|
<div class="setting-row__title">Keep password history for</div>
|
||||||
|
<div class="setting-row__desc">History entries older than this are pruned on save.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
|
<select id="history-retention" style="font-size:12px;">
|
||||||
|
<option value="last_n:5">Last 5</option>
|
||||||
|
<option value="last_n:10">Last 10</option>
|
||||||
|
<option value="days:90">90 days</option>
|
||||||
|
<option value="days:365">1 year</option>
|
||||||
|
<option value="forever">Forever</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="btn btn-primary" id="save-retention" style="font-size:11px;">Save retention settings</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set current select values
|
||||||
|
(document.getElementById('trash-retention') as HTMLSelectElement).value =
|
||||||
|
trashRetentionToValue(settings.trash_retention);
|
||||||
|
(document.getElementById('history-retention') as HTMLSelectElement).value =
|
||||||
|
historyRetentionToValue(settings.field_history_retention);
|
||||||
|
|
||||||
|
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||||
|
if (pendingVaultSettings) {
|
||||||
|
pendingVaultSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('history-retention')?.addEventListener('change', (e) => {
|
||||||
|
if (pendingVaultSettings) {
|
||||||
|
pendingVaultSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-retention')?.addEventListener('click', async () => {
|
||||||
|
if (!pendingVaultSettings) return;
|
||||||
|
const r = await sendMessage({ type: 'update_vault_settings', settings: pendingVaultSettings });
|
||||||
|
if (!r.ok) alert(`Save failed: ${r.error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBackupSection(content: HTMLElement): void {
|
||||||
|
content.innerHTML = `
|
||||||
|
<h3 class="settings-section-title">Backup & restore</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row__info">
|
||||||
|
<div class="setting-row__title">Export & restore backup</div>
|
||||||
|
<div class="setting-row__desc">Download an encrypted backup or restore from a file. Opens in the vault tab.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
|
<button class="btn" id="open-backup-tab" style="font-size:11px;">Open backup ▸</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('open-backup-tab')?.addEventListener('click', () => openVaultTab('backup'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImportSection(content: HTMLElement): void {
|
||||||
|
content.innerHTML = `
|
||||||
|
<h3 class="settings-section-title">Import</h3>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-row__info">
|
||||||
|
<div class="setting-row__title">Import from LastPass</div>
|
||||||
|
<div class="setting-row__desc">Import a LastPass CSV export. Opens in the vault tab for review before committing.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row__control">
|
||||||
|
<button class="btn" id="open-import-tab" style="font-size:11px;">Open import ▸</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('open-import-tab')?.addEventListener('click', () => openVaultTab('import'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { renderAutofillSection, renderDisplaySection, renderGeneratorSection,
|
||||||
|
renderRetentionSection, renderBackupSection, renderImportSection };
|
||||||
|
|
||||||
|
// Suppress unused-import warnings — these are used by Tasks 3-9
|
||||||
|
void sendMessage;
|
||||||
|
void loadColorScheme;
|
||||||
|
void saveColorScheme;
|
||||||
|
void resetColorScheme;
|
||||||
|
void DEFAULT_DIGIT_COLOR;
|
||||||
|
void DEFAULT_SYMBOL_COLOR;
|
||||||
|
void colorizePassword;
|
||||||
|
void openGeneratorPanel;
|
||||||
|
void pendingVaultSettings;
|
||||||
|
void activeKeyHandler;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { renderUnlock } from './components/unlock';
|
|||||||
import { renderItemList } from './components/item-list';
|
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, teardownSettings } from './components/settings';
|
||||||
import { renderVaultSettings } from './components/settings-vault';
|
import { renderVaultSettings } from './components/settings-vault';
|
||||||
import { renderTrash } from './components/trash';
|
import { renderTrash } from './components/trash';
|
||||||
import { renderDevices } from './components/devices';
|
import { renderDevices } from './components/devices';
|
||||||
@@ -178,6 +178,7 @@ function render(): void {
|
|||||||
teardownTrash();
|
teardownTrash();
|
||||||
teardownDevices();
|
teardownDevices();
|
||||||
teardownFieldHistory();
|
teardownFieldHistory();
|
||||||
|
teardownSettings();
|
||||||
|
|
||||||
switch (currentState.view) {
|
switch (currentState.view) {
|
||||||
case 'locked':
|
case 'locked':
|
||||||
|
|||||||
@@ -1699,3 +1699,92 @@ textarea {
|
|||||||
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
|
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
|
||||||
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
|
||||||
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* === Settings layout === */
|
||||||
|
.settings-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
width: 148px;
|
||||||
|
min-width: 148px;
|
||||||
|
border-right: 1px solid var(--border, #30363d);
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__group-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__item:hover { background: var(--bg-hover, #161b22); }
|
||||||
|
.settings-nav__item--active { background: var(--bg-selected, #1c2d41); }
|
||||||
|
|
||||||
|
.settings-nav__icon { font-size: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, #21262d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.setting-row__info { flex: 1; }
|
||||||
|
.setting-row__title { font-size: 13px; font-weight: 500; }
|
||||||
|
.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; }
|
||||||
|
.setting-row__control { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border, #30363d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-card {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #30363d);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); }
|
||||||
|
.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); }
|
||||||
|
|
||||||
|
.setting-card__status { font-size: 13px; margin-bottom: 8px; }
|
||||||
|
.setting-card__actions { display: flex; gap: 8px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user