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> {
|
||||
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<v
|
||||
return;
|
||||
}
|
||||
|
||||
const urlResult = normalizeUrl(rawUrl);
|
||||
if (!urlResult.ok) {
|
||||
setState({ error: urlResult.error });
|
||||
return;
|
||||
}
|
||||
const url = urlResult.value;
|
||||
|
||||
let totp: TotpConfig | undefined;
|
||||
if (totpStr) {
|
||||
try {
|
||||
|
||||
@@ -68,11 +68,44 @@ export function setState(partial: Partial<PopupState>): void {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user