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 2cea82d..708464e 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -1,191 +1,139 @@ -/// 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 } from '../../shared/glyphs'; +import { sendMessage, escapeHtml } from '../../shared/state'; +import type { VaultSettings } from '../../shared/types'; import { loadColorScheme, saveColorScheme, resetColorScheme, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, } from '../../shared/color-scheme'; import { colorizePassword } from '../../shared/password-coloring'; +import { openGeneratorPanel, closeGeneratorPanel } 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 [settingsResp, blacklistResp] = await Promise.all([ - sendMessage({ type: 'get_settings' }), - sendMessage({ type: 'get_blacklist' }), - ]); +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' }, +]; - const settings: DeviceSettings = settingsResp.ok - ? (settingsResp.data as { settings: DeviceSettings }).settings - : { captureEnabled: false, captureStyle: 'bar' }; - - const blacklist: string[] = blacklistResp.ok - ? (blacklistResp.data as { blacklist: string[] }).blacklist - : []; - - const blacklistHtml = blacklist.length > 0 - ? blacklist.map((h) => ` -
- ${escapeHtml(h)} - -
- `).join('') - : '

no blacklisted sites

'; - - app.innerHTML = ` -
-
- - settings -
- -
- -
- -
-
prompt style
-
- - -
-
- -
- - - -
-
- -
-
- -
-
blacklisted sites
-
- ${blacklistHtml} -
-
-
- `; - - // 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 } }); - }); - - // Style buttons - document.getElementById('style-bar')?.addEventListener('click', async () => { - await sendMessage({ type: 'update_settings', settings: { captureStyle: 'bar' } }); - renderSettings(app); - }); - - document.getElementById('style-toast')?.addEventListener('click', async () => { - await sendMessage({ type: 'update_settings', settings: { captureStyle: 'toast' } }); - renderSettings(app); - }); - - // Blacklist remove buttons - document.querySelectorAll('.relicario-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); - } - }); - }); - - // 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; - - const scheme = await loadColorScheme(); +let activeSection: SettingsSection = 'autofill'; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; +let pendingVaultSettings: VaultSettings | null = null; +export async function renderSettings(container: HTMLElement): Promise { container.innerHTML = ` -
display
-
- -
-
- -
-
-
- +
+ +
`; - 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); - } - - digitInput.addEventListener('change', () => void onColorChange()); - symbolInput.addEventListener('change', () => void onColorChange()); - - document.getElementById('display-reset')?.addEventListener('click', async () => { - await resetColorScheme(); - digitInput.value = DEFAULT_DIGIT_COLOR; - symbolInput.value = DEFAULT_SYMBOL_COLOR; - updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR); - }); + wireNav(); + await renderSection(activeSection); } + +export function teardownSettings(): void { + closeGeneratorPanel(); + teardownSecuritySection(); + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + pendingVaultSettings = 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, null); + 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 { + content.innerHTML = '

Autofill — coming soon

'; +} + +function renderDisplaySection(content: HTMLElement): void { + content.innerHTML = '

Display — coming soon

'; +} + +async function renderGeneratorSection(content: HTMLElement): Promise { + content.innerHTML = '

Generator — coming soon

'; +} + +async function renderRetentionSection(content: HTMLElement): Promise { + content.innerHTML = '

Retention — coming soon

'; +} + +function renderBackupSection(content: HTMLElement): void { + content.innerHTML = '

Backup — coming soon

'; +} + +function renderImportSection(content: HTMLElement): void { + content.innerHTML = '

Import — coming soon

'; +} + +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/styles.css b/extension/src/popup/styles.css index 5379e8e..1190b54 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1573,3 +1573,92 @@ textarea { margin-top: 8px; background: var(--bg-input); } + +/* === 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; }