diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index b58dde1..58b68c1 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -163,11 +163,32 @@ function goBack(mode: 'add' | 'edit'): void { } } +/// Normalize a URL input so the Rust-side `url::Url::parse` accepts it. +/// +/// Prepends `https://` when the input looks like a bare host (no scheme), +/// then validates via the JS URL constructor. Returns { ok, value, error }. +function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } { + if (!raw) return { ok: true, value: '' }; + const trimmed = raw.trim(); + // If it already has a scheme, pass through. Otherwise assume https://. + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) + ? trimmed + : `https://${trimmed}`; + try { + const u = new URL(candidate); + // url::Url rejects schemes without an authority (host). Require a host. + if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' }; + return { ok: true, value: u.toString() }; + } catch { + return { ok: false, error: 'URL is not valid — try something like https://example.com' }; + } +} + async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise { const state = getState(); const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); - const url = (document.getElementById('f-url') as HTMLInputElement).value.trim(); + const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; const username = (document.getElementById('f-username') as HTMLInputElement).value.trim(); const password = (document.getElementById('f-password') as HTMLInputElement).value; const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim(); @@ -179,6 +200,13 @@ async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise): void { 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 {