206 lines
6.6 KiB
TypeScript
206 lines
6.6 KiB
TypeScript
/// 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 { 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';
|
|
|
|
// --- Escape HTML to prevent XSS ---
|
|
export function escapeHtml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// --- State ---
|
|
|
|
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
export function getState(): PopupState {
|
|
return currentState;
|
|
}
|
|
|
|
export function setState(partial: Partial<PopupState>): void {
|
|
currentState = { ...currentState, ...partial };
|
|
render();
|
|
}
|
|
|
|
// --- Messaging ---
|
|
|
|
export function sendMessage(request: Request): Promise<Response> {
|
|
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<PopupState>): void {
|
|
setState({ view, error: null, loading: false, ...extras });
|
|
}
|
|
|
|
// --- Render ---
|
|
|
|
function render(): void {
|
|
const app = document.getElementById('app');
|
|
if (!app) return;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// --- Init ---
|
|
|
|
async function init(): Promise<void> {
|
|
// 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;
|
|
}
|
|
navigate('list', { entries: listData.items });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
navigate('locked');
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|