fix(ext/popup): normalize url field + humanize cryptic error messages
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
|
||||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
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 username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
|
||||||
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
const password = (document.getElementById('f-password') as HTMLInputElement).value;
|
||||||
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
|
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
|
||||||
@@ -179,6 +200,13 @@ async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlResult = normalizeUrl(rawUrl);
|
||||||
|
if (!urlResult.ok) {
|
||||||
|
setState({ error: urlResult.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = urlResult.value;
|
||||||
|
|
||||||
let totp: TotpConfig | undefined;
|
let totp: TotpConfig | undefined;
|
||||||
if (totpStr) {
|
if (totpStr) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -68,11 +68,44 @@ export function setState(partial: Partial<PopupState>): void {
|
|||||||
export function sendMessage(request: Request): Promise<Response> {
|
export function sendMessage(request: Request): Promise<Response> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
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);
|
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 ---
|
// --- Navigation ---
|
||||||
|
|
||||||
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user