/// 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 { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } 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 } 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'; // --------------------------------------------------------------------------- // 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 typeIcon(t: ItemType): string { switch (t) { case 'login': return '\u{1F511}'; // key case 'secure_note': return '\u{1F4DD}'; // memo case 'identity': return '\u{1FAAA}'; // id card case 'card': return '\u{1F4B3}'; // credit card case 'key': return '\u{1F5DD}'; // old key case 'document': return '\u{1F4C4}'; // page facing up case 'totp': return '⏱'; // stopwatch } } function typeLabel(t: ItemType): string { switch (t) { case 'login': return 'Logins'; case 'secure_note': return 'Secure Notes'; case 'identity': return 'Identities'; case 'card': return 'Cards'; case 'key': return 'Keys'; case 'document': return 'Documents'; case 'totp': return 'TOTP'; } } // --------------------------------------------------------------------------- // 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; 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, 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); renderSidebarList(); 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
${state.error ? `
${escapeHtml(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 (sidebar + pane) // --------------------------------------------------------------------------- function renderShell(app: HTMLElement): void { // Only create the shell structure if it's not present yet if (!app.querySelector('.vault-sidebar')) { app.innerHTML = `
Relicario
select an item
`; wireSidebar(); } renderSidebarList(); renderPane(); } // --------------------------------------------------------------------------- // Sidebar wiring // --------------------------------------------------------------------------- function wireSidebar(): void { // Search const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { state.searchQuery = searchInput.value; renderSidebarList(); }); // 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; setHash('add'); renderPane(); 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 document.addEventListener('keydown', (e) => { if (e.key === '/' && !isEditableTarget(e.target)) { e.preventDefault(); searchInput?.focus(); } }); } 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 list // --------------------------------------------------------------------------- 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 renderSidebarList(): void { const container = document.getElementById('vault-sidebar-list'); if (!container) return; const filtered = getFilteredEntries(); // Group by type const groups = new Map>(); for (const entry of filtered) { const t = entry[1].type; if (!groups.has(t)) groups.set(t, []); groups.get(t)!.push(entry); } if (filtered.length === 0) { container.innerHTML = '
no items
'; return; } let html = ''; // Stable type ordering const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; for (const t of typeOrder) { const items = groups.get(t); if (!items || items.length === 0) continue; html += `
${typeIcon(t)} ${escapeHtml(typeLabel(t))}
`; for (const [id, e] of items) { const sel = id === state.selectedId ? ' selected' : ''; const meta = e.icon_hint ? escapeHtml(e.icon_hint) : ''; html += `
${escapeHtml(e.title)} ${meta ? `` : ''}
`; } } container.innerHTML = html; // Wire clicks container.querySelectorAll('.vault-entry').forEach((el) => { el.addEventListener('click', async () => { const id = (el as HTMLElement).dataset.id!; await selectItem(id); }); }); } 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); renderSidebarList(); renderPane(); } else { state.loading = false; state.error = (resp as { error: string }).error; } } // --------------------------------------------------------------------------- // 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 typeLabel = itemType.replace('_', ' '); const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`; 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(); 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': 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 () => { // 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(); renderSidebarList(); 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; renderSidebarList(); renderPane(); }); });