/// Vault tab entry point — full "desktop-like" sidebar + pane layout. /// /// Registers as the shared state host so popup components (item-detail, /// item-form, trash, devices, settings, etc.) render natively in the /// vault tab's pane area. import type { Request, Response } from '../shared/messages'; import type { ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, } from '../shared/types'; import { registerHost } from '../shared/state'; import { type ErrorCta } from '../shared/error-copy'; import { relativeTime } from '../shared/relative-time'; import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; import { renderDevices, teardown as teardownDevices } from '../popup/components/devices'; import { renderSettings, teardownSettings } from '../popup/components/settings'; import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; import { renderItemHistoryIndex, teardown as teardownHistoryIndex } from '../popup/components/item-history-index'; import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { type VaultController, type VaultState, type VaultView, type HashRoute, escapeHtml, typeIcon, typeLabel, getFilteredEntries, } from './vault-context'; import { render, applyShellViewClass, openTypePanel, closeTypePanel, applyVaultColorScheme, wireSessionExpiredListener, } from './vault-shell'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { // MV3 service workers are evicted after ~30s idle, which wipes the // in-memory session/manifest/gitHost. The fullscreen tab stays open // and has no signal that the SW restarted — the next RPC just comes // back `vault_locked`. Treat that as "session lost" and force the // lock screen so the user can re-enter their passphrase. Skip for // is_unlocked / unlock themselves to avoid loops on cold start. if ( response && !response.ok && response.error === 'vault_locked' && request.type !== 'is_unlocked' && request.type !== 'unlock' && state.unlocked ) { state.unlocked = false; state.selectedId = null; state.selectedItem = null; state.entries = []; state.error = 'Session expired — please unlock again.'; render(ctx); } resolve(response); }); }); } // --------------------------------------------------------------------------- // Hash routing // --------------------------------------------------------------------------- function parseHash(): HashRoute { let raw = window.location.hash.replace(/^#\/?/, ''); if (!raw) return { view: 'list' }; // Normalize legacy bookmarks: #field-history/ → #history/ if (raw.startsWith('field-history/')) { raw = 'history/' + raw.slice('field-history/'.length); window.location.hash = raw; } const parts = raw.split('/'); const view = parts[0] as VaultView; switch (view) { case 'detail': case 'edit': return { view, id: parts[1] }; case 'add': return { view, type: parts[1] }; case 'history': return parts[1] ? { view: 'field-history', id: parts[1] } : { view: 'history' }; case 'trash': case 'devices': case 'settings': case 'settings-vault': case 'field-history': case 'backup': case 'import': return { view }; default: return { view: 'list' }; } } function setHash(view: VaultView, param?: string): void { const fragment = param ? `${view}/${param}` : view; window.location.hash = fragment === 'list' ? '' : fragment; } // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- const state: VaultState = { unlocked: false, view: 'list', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, drawerOpen: false, typePanelOpen: false, vaultSettings: null, generatorDefaults: null, error: null, loading: false, newType: null, capturedTabId: null, capturedUrl: '', historyItemId: null, }; // --------------------------------------------------------------------------- // Controller — carries state + cross-module re-render hooks // --------------------------------------------------------------------------- const ctx: VaultController = { state, sendMessage, render: () => render(ctx), renderPane: () => renderPane(), renderListPane: () => renderListPane(), renderSidebarCategories: () => renderSidebarCategories(), renderDrawer: (item) => renderDrawer(item), applyShellViewClass: () => applyShellViewClass(ctx), setHash, openDrawer: () => openDrawer(), closeDrawer: () => closeDrawer(), selectItemForDrawer: (id) => selectItemForDrawer(id), openTypePanel: () => openTypePanel(ctx), closeTypePanel: () => closeTypePanel(ctx), wireSidebar: () => wireSidebar(), loadManifest: () => loadManifest(), }; // --------------------------------------------------------------------------- // Register as shared state host // --------------------------------------------------------------------------- registerHost({ getState: () => state, setState: (partial) => { Object.assign(state, partial); renderPane(); }, navigate: (view, extras) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); applyShellViewClass(ctx); renderSidebarCategories(); if (state.view === 'list') renderListPane(); renderPane(); }, sendMessage, escapeHtml, popOutToTab: () => {}, isInTab: () => true, openVaultTab: () => {}, }); // --------------------------------------------------------------------------- // Drawer (implemented in Task 10) // --------------------------------------------------------------------------- function openDrawer(): void { document.getElementById('vault-drawer')?.classList.add('vault-drawer--open'); } function closeDrawer(): void { state.drawerOpen = false; state.selectedId = null; state.selectedItem = null; document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open'); } function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> { const core = item.core as unknown as Record; if (!core) return []; const fields: Array<[string, string, boolean]> = []; switch (item.type) { case 'login': if ('username' in core) fields.push(['username', String(core.username ?? ''), false]); if ('password' in core) fields.push(['password', '••••••••', false]); if ('url' in core) fields.push(['url', String(core.url ?? ''), true]); break; case 'card': { if ('number' in core) fields.push(['number', String(core.number ?? ''), false]); if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]); if ('expiry' in core && core.expiry) { const exp = core.expiry as { month: number; year: number }; fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]); } if ('cvv' in core) fields.push(['cvv', '•••', false]); if ('pin' in core) fields.push(['pin', '••••', false]); break; } case 'identity': if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]); if ('email' in core) fields.push(['email', String(core.email ?? ''), true]); if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]); if ('address' in core) fields.push(['address', String(core.address ?? ''), true]); if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]); break; case 'key': if ('label' in core) fields.push(['label', String(core.label ?? ''), true]); if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]); if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]); break; case 'secure_note': if ('body' in core) fields.push(['body', String(core.body ?? ''), true]); break; case 'totp': if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]); if ('label' in core) fields.push(['label', String(core.label ?? ''), false]); break; case 'document': if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]); if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]); break; } if (item.notes) fields.push(['notes', item.notes, true]); return fields; } function renderDrawer(item: Item): void { const drawer = document.getElementById('vault-drawer'); if (!drawer) return; const coreFields = getDrawerCoreFields(item); drawer.innerHTML = `
${item.type.replace('_', ' ').toUpperCase()}
${escapeHtml(item.title)}
${item.type === 'login' && (item.core as { url?: string }).url ? `
${escapeHtml((item.core as { url?: string }).url ?? '')}
` : ''}
${coreFields.map(([label, value, full]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join('')}
`; document.getElementById('drawer-close-btn')?.addEventListener('click', () => { closeDrawer(); renderListPane(); }); document.getElementById('drawer-edit-btn')?.addEventListener('click', () => { if (state.selectedId) { setHash('edit', state.selectedId); renderPane(); } }); } // --------------------------------------------------------------------------- // Item selection (implemented in Task 10) // --------------------------------------------------------------------------- async function selectItemForDrawer(id: string): Promise { const resp = await sendMessage({ type: 'get_item', id }); if (!resp.ok) return; const data = resp.data as { item: Item }; state.selectedId = id; state.selectedItem = data.item; state.drawerOpen = true; renderSidebarCategories(); renderListPane(); renderDrawer(data.item); openDrawer(); } // --------------------------------------------------------------------------- // Sidebar wiring // --------------------------------------------------------------------------- function wireSidebar(): void { // Search const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { state.searchQuery = searchInput.value; renderSidebarCategories(); renderListPane(); }); // Nav buttons document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => { btn.addEventListener('click', async () => { const nav = (btn as HTMLElement).dataset.nav; if (nav === 'lock') { await sendMessage({ type: 'lock' }); state.unlocked = false; state.selectedId = null; state.selectedItem = null; state.entries = []; render(ctx); return; } if (nav === 'add') { state.selectedId = null; state.selectedItem = null; state.newType = null; state.drawerOpen = false; closeDrawer(); openTypePanel(ctx); return; } if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') { state.selectedId = null; state.selectedItem = null; state.newType = null; state.drawerOpen = false; state.view = nav; setHash(nav); applyShellViewClass(ctx); renderPane(); return; } }); }); // Global "/" shortcut to focus search; Esc to close drawer document.addEventListener('keydown', (e) => { if (e.key === '/' && !isEditableTarget(e.target)) { e.preventDefault(); searchInput?.focus(); return; } if (e.key === 'Escape' && state.drawerOpen) { closeDrawer(); renderListPane(); } }); } function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if (target.isContentEditable) return true; return false; } // --------------------------------------------------------------------------- // Sidebar category nav // --------------------------------------------------------------------------- function renderSidebarCategories(): void { const container = document.getElementById('vault-categories'); if (!container) return; const filtered = getFilteredEntries(state); const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; const allCount = filtered.length; const isAllActive = !state.activeGroup && state.view === 'list'; let html = ` `; for (const t of typeOrder) { const count = filtered.filter(([, e]) => e.type === t).length; // Always show Login (staple type); hide other types when empty. if (count === 0 && t !== 'login') continue; const isActive = state.activeGroup === t; html += ` `; } container.innerHTML = html; container.querySelectorAll('.vault-category-row').forEach((btn) => { btn.addEventListener('click', () => { state.activeGroup = btn.dataset.group || null; state.drawerOpen = false; state.selectedId = null; state.selectedItem = null; state.view = 'list'; setHash('list'); applyShellViewClass(ctx); renderSidebarCategories(); renderListPane(); closeDrawer(); }); }); } // --------------------------------------------------------------------------- // List pane // --------------------------------------------------------------------------- function renderListPane(): void { const pane = document.getElementById('vault-list-pane'); if (!pane) return; const group = state.activeGroup as ItemType | null; let items = getFilteredEntries(state); if (group) items = items.filter(([, e]) => e.type === group); if (items.length === 0) { pane.innerHTML = `
${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}
${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}
`; return; } pane.innerHTML = items.map(([id, e]) => { const sel = id === state.selectedId ? ' vault-list-row--selected' : ''; const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : ''); const modifiedAgo = e.modified ? relativeTime(e.modified) : ''; return `
${escapeHtml(e.title)}
${subtitle ? `
${escapeHtml(subtitle)}
` : ''}
${modifiedAgo ? `
${escapeHtml(modifiedAgo)}
` : ''}
`; }).join(''); pane.querySelectorAll('.vault-list-row').forEach((row) => { row.addEventListener('click', async () => { await selectItemForDrawer(row.dataset.id!); }); }); } // --------------------------------------------------------------------------- // Platform-aware save hint // --------------------------------------------------------------------------- const isMac = navigator.platform.toLowerCase().includes('mac'); const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; // --------------------------------------------------------------------------- // Fullscreen form wrapper — sticky save bar + scrollable content + header // --------------------------------------------------------------------------- function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; const typeLabelText = itemType.replace('_', ' '); const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`; const wrapper = document.createElement('div'); wrapper.className = 'form-pane'; wrapper.innerHTML = `
${titleText}
no changes
${SAVE_HINT}
`; // Remove pane padding so form-pane can fill height cleanly app.style.padding = '0'; app.style.overflow = 'hidden'; app.replaceChildren(wrapper); const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; renderItemForm(scrollEl, mode); const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement; let isDirty = false; const markDirty = () => { if (isDirty) return; isDirty = true; subEl.textContent = 'unsaved · esc to cancel'; }; const markClean = () => { isDirty = false; subEl.textContent = 'no changes'; }; scrollEl.addEventListener('input', markDirty, true); scrollEl.addEventListener('change', markDirty, true); wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { markClean(); (scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click(); }); wrapper.querySelector('#form-save')?.addEventListener('click', () => { markClean(); (scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click(); }); } export const __test__ = { renderFormWrapped }; // --------------------------------------------------------------------------- // Pane rendering — delegates to shared popup components // --------------------------------------------------------------------------- function teardownPaneComponents(): void { teardownTrash(); teardownDevices(); teardownSettings(); teardownFieldHistory(); teardownHistoryIndex(); teardownBackup(); teardownImport(); } function renderPane(): void { const pane = document.getElementById('vault-pane'); if (!pane) return; teardownPaneComponents(); const route = parseHash(); // Keep state.view in sync with hash for components that read it state.view = route.view; applyShellViewClass(ctx); pane.className = 'vault-pane'; switch (route.view) { case 'detail': if (state.selectedItem) { renderItemDetail(pane); } else { pane.className = 'vault-pane vault-pane--empty'; pane.innerHTML = 'select an item'; } break; case 'add': // Prefer hash type for deep-links; otherwise keep the in-memory value // set by the type-selection click handler (which calls setState → // renderPane before the URL hash has been updated to include the type). state.newType = (route.type as ItemType) ?? state.newType ?? null; // Use the form wrapper (sticky bar + header) when a type is already chosen. // Without a type the type-selection screen renders — no sticky bar needed. if (state.newType) { renderFormWrapped(pane, 'add'); } else { renderItemForm(pane, 'add'); } break; case 'edit': renderFormWrapped(pane, 'edit'); break; case 'trash': renderTrash(pane); break; case 'devices': renderDevices(pane); break; case 'settings': void renderSettings(pane); break; case 'settings-vault': renderVaultSettingsView(pane); break; case 'field-history': renderFieldHistory(pane); break; case 'history': renderItemHistoryIndex(pane); break; case 'backup': renderBackupPanel(pane); break; case 'import': renderImportPanel(pane); break; default: pane.className = 'vault-pane vault-pane--empty'; pane.innerHTML = 'select an item'; break; } } // --------------------------------------------------------------------------- // Data loading // --------------------------------------------------------------------------- async function loadManifest(): Promise { const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; state.entries = data.items; } const vsResp = await sendMessage({ type: 'get_vault_settings' }); if (vsResp.ok) { const data = vsResp.data as { settings: VaultSettings }; state.vaultSettings = data.settings; state.generatorDefaults = data.settings.generator_defaults; } // Handle deep link from hash const route = parseHash(); if (route.view === 'detail' && route.id) { const itemResp = await sendMessage({ type: 'get_item', id: route.id }); if (itemResp.ok) { const data = itemResp.data as { item: Item }; state.selectedId = route.id; state.selectedItem = data.item; } } } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- document.addEventListener('DOMContentLoaded', async () => { await applyVaultColorScheme(); // Delegated handler for .error-cta buttons — set up once on the stable root. const app = document.getElementById('vault-app')!; app.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('.error-cta'); if (!btn) return; const cta = btn.dataset.cta as ErrorCta['action']; switch (cta) { case 'unlock': { document.getElementById('vault-passphrase')?.focus(); break; } case 'open_setup': { void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); break; } case 'reload_extension': { chrome.runtime.reload(); break; } } }); // Check if already unlocked const resp = await sendMessage({ type: 'is_unlocked' }); if (resp.ok) { const data = resp.data as { unlocked: boolean }; if (data.unlocked) { state.unlocked = true; await loadManifest(); } } render(ctx); wireSessionExpiredListener(ctx); // Hash change listener window.addEventListener('hashchange', () => { if (!state.unlocked) return; const route = parseHash(); state.view = route.view; applyShellViewClass(ctx); // If navigating to a detail/edit view for an item we already have loaded if ((route.view === 'detail' || route.view === 'edit') && route.id) { if (state.selectedId === route.id && state.selectedItem) { renderPane(); renderSidebarCategories(); if (state.view === 'list') renderListPane(); return; } // Need to fetch the item selectItem(route.id); return; } // For non-item views, just re-render the pane state.selectedId = null; state.selectedItem = null; renderSidebarCategories(); if (state.view === 'list') renderListPane(); renderPane(); }); }); // --------------------------------------------------------------------------- // Legacy selectItem — used by hash-change deep linking // --------------------------------------------------------------------------- async function selectItem(id: ItemId): Promise { state.loading = true; const resp = await sendMessage({ type: 'get_item', id }); if (resp.ok) { const data = resp.data as { item: Item }; state.selectedId = id; state.selectedItem = data.item; state.loading = false; setHash('detail', id); renderSidebarCategories(); renderListPane(); renderPane(); } else { state.loading = false; state.error = (resp as { error: string }).error; } }