/// 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 { lookupErrorCopy, type ErrorCta } from '../shared/error-copy'; import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, } from '../shared/glyphs'; 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 { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { applyColorScheme } from '../shared/color-scheme'; // --------------------------------------------------------------------------- // Bottom sheet type picker // --------------------------------------------------------------------------- const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [ { type: 'login', label: 'Login' }, { type: 'secure_note', label: 'Secure Note' }, { type: 'totp', label: 'TOTP' }, { type: 'card', label: 'Card' }, { type: 'identity', label: 'Identity' }, { type: 'key', label: 'SSH / API Key' }, { type: 'document', label: 'Document' }, ]; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { resolve(response); }); }); } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderErrorBlock(code: string | null | undefined): string { if (!code) return ''; const copy = lookupErrorCopy(code); const ctaHtml = copy.cta ? `` : ''; return `
${escapeHtml(copy.title)}
${escapeHtml(copy.body)}
${ctaHtml}
`; } function typeIcon(t: ItemType): string { switch (t) { case 'login': return GLYPH_TYPE_LOGIN; case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; case 'identity': return GLYPH_TYPE_IDENTITY; case 'card': return GLYPH_TYPE_CARD; case 'key': return GLYPH_TYPE_KEY; case 'document': return GLYPH_TYPE_DOCUMENT; case 'totp': return GLYPH_TYPE_TOTP; } } function typeLabel(t: ItemType): string { const labels: Record = { login: 'Login', secure_note: 'Secure Note', identity: 'Identity', card: 'Card', key: 'SSH / API Key', document: 'Document', totp: 'TOTP', }; return labels[t]; } function relativeTime(unixSec: number): string { const diffS = Math.floor(Date.now() / 1000) - unixSec; if (diffS < 60) return 'just now'; if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`; if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`; return `${Math.floor(diffS / 86400)}d ago`; } // --------------------------------------------------------------------------- // Hash routing // --------------------------------------------------------------------------- type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import'; interface HashRoute { view: VaultView; id?: string; type?: string; } function parseHash(): HashRoute { const raw = window.location.hash.replace(/^#\/?/, ''); if (!raw) return { view: 'list' }; 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 '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 // --------------------------------------------------------------------------- interface VaultState { unlocked: boolean; view: VaultView; entries: Array<[ItemId, ManifestEntry]>; selectedId: ItemId | null; selectedItem: Item | null; selectedIndex: number; searchQuery: string; activeGroup: string | null; drawerOpen: boolean; bottomSheetOpen: boolean; vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; error: string | null; loading: boolean; newType: ItemType | null; capturedTabId: number | null; capturedUrl: string; historyItemId: ItemId | null; } const state: VaultState = { unlocked: false, view: 'list', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, drawerOpen: false, bottomSheetOpen: false, vaultSettings: null, generatorDefaults: null, error: null, loading: false, newType: null, capturedTabId: null, capturedUrl: '', historyItemId: null, }; // --------------------------------------------------------------------------- // Register as shared state host // --------------------------------------------------------------------------- registerHost({ getState: () => state, setState: (partial: any) => { Object.assign(state, partial); renderPane(); }, navigate: (view: string, extras?: any) => { Object.assign(state, { view, error: null, loading: false, ...extras }); setHash(view as VaultView); renderSidebarCategories(); renderListPane(); renderPane(); }, sendMessage, escapeHtml, popOutToTab: () => {}, isInTab: () => true, openVaultTab: () => {}, }); // --------------------------------------------------------------------------- // Render entry point // --------------------------------------------------------------------------- function render(): void { const app = document.getElementById('vault-app'); if (!app) return; if (!state.unlocked) { renderLockScreen(app); } else { renderShell(app); } } // --------------------------------------------------------------------------- // Lock screen // --------------------------------------------------------------------------- function renderLockScreen(app: HTMLElement): void { app.innerHTML = `
Relicario
${renderErrorBlock(state.error)}
`; const input = document.getElementById('vault-passphrase') as HTMLInputElement; const btn = document.getElementById('vault-unlock-btn')!; const doUnlock = async () => { const passphrase = input.value; if (!passphrase) return; btn.textContent = 'unlocking...'; btn.setAttribute('disabled', 'true'); const resp = await sendMessage({ type: 'unlock', passphrase }); if (resp.ok) { state.unlocked = true; state.error = null; await loadManifest(); render(); } else { state.error = resp.error ?? 'unlock failed'; render(); } }; btn.addEventListener('click', doUnlock); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doUnlock(); }); input.focus(); } // --------------------------------------------------------------------------- // Shell (3-column: sidebar + list pane + drawer) // --------------------------------------------------------------------------- function renderShell(app: HTMLElement): void { if (!app.querySelector('.vault-shell')) { app.innerHTML = `
Relicario
`; wireSidebar(); wireBottomSheet(); } renderSidebarCategories(); renderListPane(); if (state.drawerOpen && state.selectedItem) { renderDrawer(state.selectedItem); } } // --------------------------------------------------------------------------- // Bottom sheet (wired in Task 11) // --------------------------------------------------------------------------- function wireBottomSheet(): void { document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet(); }); } function openBottomSheet(): void { const sheet = document.getElementById('vault-bottom-sheet'); const scrim = document.getElementById('vault-sheet-scrim'); if (!sheet || !scrim) return; sheet.innerHTML = `
New item — choose type
${BOTTOM_SHEET_TYPES.map((t) => ` `).join('')}
`; sheet.classList.add('vault-bottom-sheet--open'); scrim.classList.add('vault-bottom-sheet-scrim--visible'); state.bottomSheetOpen = true; sheet.querySelectorAll('[data-type]').forEach((btn) => { btn.addEventListener('click', () => { const type = btn.dataset.type as ItemType; closeBottomSheet(); setHash('add', type); renderPane(); }); }); } function closeBottomSheet(): void { document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open'); document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible'); state.bottomSheetOpen = false; } // --------------------------------------------------------------------------- // 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(); return; } if (nav === 'add') { state.selectedId = null; state.selectedItem = null; state.newType = null; openBottomSheet(); return; } if (nav === 'trash' || nav === 'devices' || nav === 'settings') { state.selectedId = null; state.selectedItem = null; state.newType = null; setHash(nav); 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 getFilteredEntries(): Array<[ItemId, ManifestEntry]> { let filtered = state.entries.filter( ([, e]) => e.trashed_at === undefined || e.trashed_at === null, ); if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); filtered = filtered.filter(([, e]) => { if (e.title.toLowerCase().includes(q)) return true; if (e.icon_hint?.toLowerCase().includes(q)) return true; if (e.group?.toLowerCase().includes(q)) return true; if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; return false; }); } filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); return filtered; } function renderSidebarCategories(): void { const container = document.getElementById('vault-categories'); if (!container) return; const filtered = getFilteredEntries(); 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; if (count === 0 && allCount > 0) 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; 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(); 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(); 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; 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 '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 applyColorScheme(); chrome.storage.onChanged.addListener((changes, area) => { if (area === 'sync' && 'password_display_scheme' in changes) { void applyColorScheme(); } }); // 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(); // Session expired listener chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'session_expired') { state.unlocked = false; state.selectedId = null; state.selectedItem = null; state.entries = []; state.error = null; render(); } }); // Hash change listener window.addEventListener('hashchange', () => { if (!state.unlocked) return; const route = parseHash(); // 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(); 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(); 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; } }