diff --git a/extension/src/popup/components/__tests__/settings-nav.test.ts b/extension/src/popup/components/__tests__/settings-nav.test.ts new file mode 100644 index 0000000..544665c --- /dev/null +++ b/extension/src/popup/components/__tests__/settings-nav.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.stubGlobal('chrome', { + storage: { + local: { + get: vi.fn((_keys: unknown, cb: (r: Record) => 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'); + }); +}); diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index ab4b60c..a4021ba 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -1,18 +1,111 @@ -/// Settings view — capture toggle, prompt style, and blacklist management. - -import { sendMessage, navigate, escapeHtml } from '../../shared/state'; -import type { DeviceSettings } from '../../shared/types'; -import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SYNC } from '../../shared/glyphs'; +import { sendMessage, escapeHtml, openVaultTab } from '../../shared/state'; +import type { VaultSettings, DeviceSettings, TrashRetention, HistoryRetention } from '../../shared/types'; +import type { ColorScheme } from '../../shared/color-scheme'; import { loadColorScheme, saveColorScheme, resetColorScheme, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, } from '../../shared/color-scheme'; 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 { - app.innerHTML = '
'; +type SettingsSection = + | '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 { + container.innerHTML = ` +
+ +
+
+ `; + + 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 ` + + `; +} + +function wireNav(): void { + document.getElementById('settings-nav')?.querySelectorAll('[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 { + 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 { const [settingsResp, blacklistResp] = await Promise.all([ sendMessage({ type: 'get_settings' }), sendMessage({ type: 'get_blacklist' }), @@ -26,171 +119,314 @@ export async function renderSettings(app: HTMLElement): Promise { ? (blacklistResp.data as { blacklist: string[] }).blacklist : []; - const blacklistHtml = blacklist.length > 0 - ? blacklist.map((h) => ` -
- ${escapeHtml(h)} - -
- `).join('') - : '

no blacklisted sites

'; - - app.innerHTML = ` -
-
- - settings + content.innerHTML = ` +

Capture

+
+
+
Auto-detect logins
+
Show a prompt when a login form is detected.
- -
- +
+
- -
-
prompt style
-
- - -
+
+
+
+
Prompt style
+
How to prompt when a login is detected.
- -
- - - -
+
+ +
+
-
-
- -
-
blacklisted sites
-
- ${blacklistHtml} -
-
+

Blocked sites

+
+ ${blacklist.length > 0 + ? blacklist.map((h) => ` +
+
+
${escapeHtml(h)}
+
+ +
+ `).join('') + : '

No blocked sites.

'}
`; - // 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) => { - const checked = (e.target as HTMLInputElement).checked; - await sendMessage({ type: 'update_settings', settings: { captureEnabled: checked } }); + const enabled = (e.target as HTMLInputElement).checked; + await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } }); }); - // Style buttons document.getElementById('style-bar')?.addEventListener('click', async () => { await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } }); - renderSettings(app); + renderAutofillSection(content); }); document.getElementById('style-toast')?.addEventListener('click', async () => { await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } }); - renderSettings(app); + renderAutofillSection(content); }); - // Blacklist remove buttons - document.querySelectorAll('.relicario-remove-bl').forEach((btn) => { + content.querySelectorAll('.remove-bl').forEach((btn) => { btn.addEventListener('click', async () => { - const hostname = (btn as HTMLElement).dataset.hostname; - if (hostname) { - await sendMessage({ type: 'remove_blacklist', hostname }); - renderSettings(app); - } + const host = btn.dataset.hostname; + if (!host) return; + await sendMessage({ type: 'remove_blacklist', hostname: host }); + renderAutofillSection(content); }); }); - - // Render Display section after the rest of the DOM is ready - await renderDisplaySection(); } -function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): 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 { - // The Display section container must be present in the DOM before we call this - const container = document.getElementById('display-section-container'); - if (!container) return; - +async function renderDisplaySection(content: HTMLElement): Promise { const scheme = await loadColorScheme(); - container.innerHTML = ` -
display
-
- + content.innerHTML = ` +

Password coloring

+
+
+
Digit color
+
+
+ +
-
- +
+
+
Symbol color
+
+
+ +
-
-
- +
+
+
Preview
+
+
+ +
+
+
+
`; - const digitInput = document.getElementById('display-digit-color') as HTMLInputElement; - const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement; - const swatch = document.getElementById('display-swatch') as HTMLElement; - - // Render initial swatch - updateSwatch(swatch, scheme.digit_color, scheme.symbol_color); - - async function onColorChange(): Promise { - const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value }; - await saveColorScheme(newScheme); - updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color); + function refreshPreview(s: ColorScheme): void { + const preview = document.getElementById('color-preview'); + if (!preview) return; + preview.style.setProperty('--relicario-pwd-digit-color', s.digit_color); + preview.style.setProperty('--relicario-pwd-symbol-color', s.symbol_color); + preview.innerHTML = ''; + preview.appendChild(colorizePassword('Abc123!@#')); } - digitInput.addEventListener('change', () => void onColorChange()); - symbolInput.addEventListener('change', () => void onColorChange()); + refreshPreview(scheme); - 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(); - digitInput.value = DEFAULT_DIGIT_COLOR; - symbolInput.value = DEFAULT_SYMBOL_COLOR; - updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR); + renderDisplaySection(content); }); } -// DEV-B interface contract stub — will be replaced with real teardown logic at merge time -export function teardownSettings(): void { - // no-op stub +async function renderGeneratorSection(content: HTMLElement): Promise { + content.innerHTML = '

Loading…

'; + const resp = await sendMessage({ type: 'get_vault_settings' }); + if (!resp.ok) { + const errorMsg = (resp as { ok: false; error: string }).error; + content.innerHTML = `

Failed to load: ${escapeHtml(errorMsg)}

`; + return; + } + const settings = (resp.data as { settings: VaultSettings }).settings; + + content.innerHTML = ` +

Generator defaults

+
+
+
Configure generator
+
Password length, character classes, passphrase word count.
+
+
+ +
+
+ `; + + 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 { + content.innerHTML = '

Loading…

'; + const resp = await sendMessage({ type: 'get_vault_settings' }); + if (!resp.ok) { + content.innerHTML = `

Failed to load: ${escapeHtml(resp.error ?? 'unknown')}

`; + return; + } + const settings = (resp.data as { settings: VaultSettings }).settings; + pendingVaultSettings = { ...settings }; + + content.innerHTML = ` +

Trash retention

+
+
+
Keep deleted items for
+
Items in trash older than this are permanently deleted on the next sync.
+
+
+ +
+
+ +

Field history retention

+
+
+
Keep password history for
+
History entries older than this are pruned on save.
+
+
+ +
+
+
+ +
+ `; + + // 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 = ` +

Backup & restore

+
+
+
Export & restore backup
+
Download an encrypted backup or restore from a file. Opens in the vault tab.
+
+
+ +
+
+ `; + document.getElementById('open-backup-tab')?.addEventListener('click', () => openVaultTab('backup')); +} + +function renderImportSection(content: HTMLElement): void { + content.innerHTML = ` +

Import

+
+
+
Import from LastPass
+
Import a LastPass CSV export. Opens in the vault tab for review before committing.
+
+
+ +
+
+ `; + 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; diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index c08ea50..d098185 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -11,7 +11,7 @@ import { renderUnlock } from './components/unlock'; import { renderItemList } from './components/item-list'; import { renderItemDetail } from './components/item-detail'; import { renderItemForm } from './components/item-form'; -import { renderSettings } from './components/settings'; +import { renderSettings, teardownSettings } from './components/settings'; import { renderVaultSettings } from './components/settings-vault'; import { renderTrash } from './components/trash'; import { renderDevices } from './components/devices'; @@ -178,6 +178,7 @@ function render(): void { teardownTrash(); teardownDevices(); teardownFieldHistory(); + teardownSettings(); switch (currentState.view) { case 'locked': diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index ee28b08..21226d1 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1699,3 +1699,92 @@ textarea { .relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; } .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; } + +/* === 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; }