/// Popup entry point — state machine with view routing. /// /// Views: setup | locked | list | detail | add | edit | settings | settings-vault /// Navigation works by updating `currentState` and calling `render()`. import type { Request, Response } from '../shared/messages'; import type { ItemId, ManifestEntry, Item } from '../shared/types'; import { registerHost } from '../shared/state'; 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 { renderVaultSettings } from './components/settings-vault'; import { renderTrash } from './components/trash'; import { renderDevices } from './components/devices'; import { renderFieldHistory } from './components/field-history'; import { teardown as teardownTrash } from './components/trash'; import { teardown as teardownDevices } from './components/devices'; import { teardown as teardownFieldHistory } from './components/field-history'; // --- Escape HTML to prevent XSS --- export function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // --- Pop out to tab --- export function isInTab(): boolean { return window.location.search.length > 0; } export function openVaultTab(hash?: string): void { const url = chrome.runtime.getURL('vault.html') + (hash ? `#${hash}` : ''); chrome.tabs.create({ url }); } export function popOutToTab(): void { const state = getState(); if (state.newType) { openVaultTab(`add/${state.newType}`); } else if (state.selectedId) { openVaultTab(`${state.view}/${state.selectedId}`); } else { openVaultTab(); } window.close(); } function parseUrlParams(): { view?: View; type?: string; id?: string } | null { const params = new URLSearchParams(window.location.search); const view = params.get('view'); if (!view) return null; return { view: view as View, type: params.get('type') ?? undefined, id: params.get('id') ?? undefined, }; } // --- State --- export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history'; export interface PopupState { view: View; entries: Array<[ItemId, ManifestEntry]>; selectedId: ItemId | null; selectedItem: Item | null; selectedIndex: number; searchQuery: string; activeGroup: string | null; error: string | null; loading: boolean; // Captured tab snapshot taken at popup-open. Used by fill_credentials // to guard against TOCTOU navigation — the SW re-checks this URL's // hostname against the tab's live URL before forwarding fill_credentials // to the content script. See router/popup-only.ts#handleFillCredentials. capturedTabId: number | null; capturedUrl: string; newType: import('../shared/types').ItemType | null; vaultSettings: import('../shared/types').VaultSettings | null; generatorDefaults: import('../shared/types').GeneratorRequest | null; historyItemId: import('../shared/types').ItemId | null; } let currentState: PopupState = { view: 'locked', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, error: null, loading: false, capturedTabId: null, capturedUrl: '', newType: null, vaultSettings: null, generatorDefaults: null, historyItemId: null, }; export function getState(): PopupState { return currentState; } export function setState(partial: Partial): void { currentState = { ...currentState, ...partial }; render(); } // --- Messaging --- export function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { if (response && !response.ok && response.error) { // Replace cryptic low-level errors with user-readable messages. response = { ok: false, error: humanizeError(response.error) }; } resolve(response); }); }); } /// Translate cryptic Rust/serde/WASM error strings into messages a user /// can act on. Unknown errors pass through unchanged. export function humanizeError(err: string): string { // URL parse failures (Rust `url::Url::parse`) bubble up through serde // as `item json: ...`. Match the core phrasing. if (/relative URL without a base/i.test(err)) { return 'URL must start with https:// or http:// (e.g. https://example.com)'; } if (/item json:/i.test(err)) { return 'Could not save item — one of the fields is in an invalid format.'; } if (/settings json:/i.test(err)) { return 'Settings are in an invalid format — try reloading the extension.'; } if (/vault_locked/i.test(err)) { return 'Vault is locked. Unlock and try again.'; } if (/origin_mismatch/i.test(err)) { return 'This login belongs to a different site — refusing to leak credentials cross-origin.'; } if (/unauthorized_sender/i.test(err)) { return 'This action is not allowed from here.'; } if (/tab_navigated|captured_tab_gone/i.test(err)) { return 'The browser tab changed before the fill could complete — try again.'; } return err; } // --- Navigation --- export function navigate(view: View, extras?: Partial): void { setState({ view, error: null, loading: false, ...extras }); } // --- Register as state host so shared components can call back --- registerHost({ getState: () => currentState, setState, navigate, sendMessage, escapeHtml, popOutToTab, isInTab, openVaultTab, }); // --- Render --- function render(): void { const app = document.getElementById('app'); if (!app) return; teardownTrash(); teardownDevices(); teardownFieldHistory(); switch (currentState.view) { case 'locked': renderUnlock(app); break; case 'list': renderItemList(app); break; case 'detail': renderItemDetail(app); break; case 'add': renderItemForm(app, 'add'); break; case 'edit': renderItemForm(app, 'edit'); break; case 'settings': renderSettings(app); break; case 'settings-vault': renderVaultSettings(app); break; case 'trash': renderTrash(app); break; case 'devices': renderDevices(app); break; case 'field-history': renderFieldHistory(app); break; } } // --- Init --- async function init(): Promise { // Snapshot the active tab at popup-open — the fill path uses this // tabId/url pair so the SW can verify the tab hasn't navigated before // forwarding credentials (audit M5 + TOCTOU close via expectedHost). const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); currentState.capturedTabId = tab?.id ?? null; currentState.capturedUrl = tab?.url ?? ''; // Check if extension is configured. const setupResp = await sendMessage({ type: 'get_setup_state' }); if (setupResp.ok) { const data = setupResp.data as { isConfigured: boolean }; if (!data.isConfigured) { await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); window.close(); return; } } // Check if vault is unlocked. const unlockResp = await sendMessage({ type: 'is_unlocked' }); if (unlockResp.ok) { const data = unlockResp.data as { unlocked: boolean }; if (data.unlocked) { // Load entries and go to list. const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; // Fetch vault settings so subsequent screens (generator popover, // settings-vault) can show current values without a round-trip. // Failures swallow silently — list view still renders; consumers // can show "settings not loaded" if needed. const vsResp = await sendMessage({ type: 'get_vault_settings' }); if (vsResp.ok) { const vs = (vsResp.data as { settings: import('../shared/types').VaultSettings }).settings; currentState.vaultSettings = vs; currentState.generatorDefaults = vs.generator_defaults; } // Check URL params for deep linking (when opened in tab) const urlParams = parseUrlParams(); if (urlParams) { currentState.entries = listData.items; if (urlParams.view === 'add' && urlParams.type) { currentState.newType = urlParams.type as import('../shared/types').ItemType; navigate('add'); return; } if ((urlParams.view === 'edit' || urlParams.view === 'detail') && urlParams.id) { // Fetch the item const itemResp = await sendMessage({ type: 'get_item', id: urlParams.id }); if (itemResp.ok) { currentState.selectedId = urlParams.id; currentState.selectedItem = (itemResp.data as { item: Item }).item; navigate(urlParams.view); return; } } } navigate('list', { entries: listData.items }); return; } } } navigate('locked'); } document.addEventListener('DOMContentLoaded', () => { init(); chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'session_expired') { currentState.view = 'locked'; currentState.error = null; currentState.selectedItem = null; currentState.selectedId = null; render(); } }); });