From 9139dd78a0dd299c786a7f8b314551848295da18 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 22 Apr 2026 19:45:55 -0400 Subject: [PATCH] fix(ext/popup): normalize url field + humanize cryptic error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: typing "Test" into an add-login form's URL field produced "item json: relative URL without a base: "Test" at line 1 column 227" in the UI banner — a serde-internal error message that no user should ever see. Two fixes: 1. Client-side URL normalization in the add/edit Login form (item-form.ts:normalizeUrl): - Empty string stays empty (URL is optional). - Scheme-less inputs get "https://" prepended so "github.com" becomes "https://github.com". - The result is run through the JS URL constructor. If that rejects OR if the result has no host, show a targeted message like "URL must include a host (e.g. https://example.com)". - Prevents the Rust-side url::Url::parse failure from ever firing for a form-shaped error. 2. Popup-side error humanizer (popup.ts:humanizeError): - Applied inside sendMessage so every UI-visible error passes through it before the state banner gets the string. - Translates: "relative URL without a base" → "URL must start with https://...", generic "item json:" / "settings json:" → form- field or corruption messages, and the sender/origin gates (vault_locked, origin_mismatch, unauthorized_sender, tab_navigated, captured_tab_gone) to user-action prompts. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-form.ts | 30 ++++++++++++++++++- extension/src/popup/popup.ts | 33 +++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) 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 {