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>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
/// Typed-item add/edit form. Slice 6 ships full Login parity; other
|
|
/// types show a coming-soon placeholder (use the CLI for now).
|
|
///
|
|
/// Carry-forward from Slice 5 review M3: on edit, trashed_at is
|
|
/// explicitly reset to undefined so stale trash state cannot survive an
|
|
/// edit. (The capture path already uses spread + fetched item; this
|
|
/// popup flow uses state.selectedItem.)
|
|
|
|
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
|
import type {
|
|
Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig,
|
|
} from '../../shared/types';
|
|
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
|
|
import { base32Decode, base32Encode } from '../../shared/base32';
|
|
|
|
// Which types support add/edit in Slice 6.
|
|
function isEditableType(t: ItemType): boolean {
|
|
return t === 'login';
|
|
}
|
|
|
|
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
|
const state = getState();
|
|
const existing = mode === 'edit' ? state.selectedItem : null;
|
|
|
|
// Determine the type we're editing/creating. Add defaults to login.
|
|
const type: ItemType = existing?.type ?? 'login';
|
|
|
|
if (!isEditableType(type)) {
|
|
renderComingSoon(app, type);
|
|
return;
|
|
}
|
|
|
|
renderLoginForm(app, mode, existing);
|
|
}
|
|
|
|
// --- Coming-soon -------------------------------------------------------
|
|
|
|
function renderComingSoon(app: HTMLElement, type: ItemType): void {
|
|
app.innerHTML = `
|
|
<div class="pad">
|
|
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
|
|
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
|
|
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
|
|
<div class="form-actions">
|
|
<button class="btn" id="back-btn">back</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
document.removeEventListener('keydown', handler);
|
|
navigate('list');
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
}
|
|
|
|
// --- Login add/edit ----------------------------------------------------
|
|
|
|
/// Encode TotpConfig secret bytes back to a base32 display string.
|
|
function totpSecretToBase32(totp: TotpConfig | undefined): string {
|
|
if (!totp) return '';
|
|
return base32Encode(new Uint8Array(totp.secret));
|
|
}
|
|
|
|
function renderLoginForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
|
const state = getState();
|
|
const existingCore = (existing?.core.type === 'login')
|
|
? (existing.core as LoginCore & { type: 'login' })
|
|
: null;
|
|
|
|
const title = existing?.title ?? '';
|
|
const url = existingCore?.url ?? '';
|
|
const username = existingCore?.username ?? '';
|
|
const password = existingCore?.password ?? '';
|
|
const totpStr = totpSecretToBase32(existingCore?.totp);
|
|
const group = existing?.group ?? '';
|
|
const notes = existing?.notes ?? '';
|
|
|
|
app.innerHTML = `
|
|
<div class="pad">
|
|
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
|
<div class="form-group">
|
|
<label class="label" for="f-title">title *</label>
|
|
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="f-url">url</label>
|
|
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="f-username">username</label>
|
|
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="f-password">password</label>
|
|
<div class="inline-row">
|
|
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
|
<button class="btn" id="gen-btn" title="generate">gen</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="f-totp">totp secret (base32)</label>
|
|
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="f-group">group</label>
|
|
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="f-notes">notes</label>
|
|
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn" id="cancel-btn">cancel</button>
|
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// --- Generate password ---
|
|
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
|
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
|
|
if (resp.ok) {
|
|
const data = resp.data as { password: string };
|
|
const pwInput = document.getElementById('f-password') as HTMLInputElement;
|
|
pwInput.value = data.password;
|
|
pwInput.type = 'text'; // Show generated password.
|
|
} else {
|
|
setState({ error: resp.error });
|
|
}
|
|
});
|
|
|
|
// --- Cancel ---
|
|
document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode));
|
|
|
|
// --- Save ---
|
|
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
|
await saveLogin(mode, existing);
|
|
});
|
|
|
|
// --- Escape to cancel ---
|
|
const escHandler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
document.removeEventListener('keydown', escHandler);
|
|
goBack(mode);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', escHandler);
|
|
|
|
// Focus the title field.
|
|
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
|
|
}
|
|
|
|
function goBack(mode: 'add' | 'edit'): void {
|
|
const s = getState();
|
|
if (mode === 'edit' && s.selectedId && s.selectedItem) {
|
|
navigate('detail');
|
|
} else {
|
|
navigate('list');
|
|
}
|
|
}
|
|
|
|
/// 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 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();
|
|
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim();
|
|
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value;
|
|
|
|
if (!title) {
|
|
setState({ error: 'Title is required' });
|
|
return;
|
|
}
|
|
|
|
const urlResult = normalizeUrl(rawUrl);
|
|
if (!urlResult.ok) {
|
|
setState({ error: urlResult.error });
|
|
return;
|
|
}
|
|
const url = urlResult.value;
|
|
|
|
let totp: TotpConfig | undefined;
|
|
if (totpStr) {
|
|
try {
|
|
const bytes = base32Decode(totpStr);
|
|
totp = {
|
|
secret: Array.from(bytes),
|
|
algorithm: 'sha1',
|
|
digits: 6,
|
|
period_seconds: 30,
|
|
kind: 'totp',
|
|
};
|
|
} catch (err) {
|
|
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const core: LoginCore & { type: 'login' } = {
|
|
type: 'login',
|
|
username: username || undefined,
|
|
password: password || undefined,
|
|
url: url || undefined,
|
|
totp,
|
|
};
|
|
|
|
// Build the Item. On edit we preserve id/created/tags/favorite/sections/
|
|
// attachments/field_history from the existing item, but we EXPLICITLY
|
|
// set trashed_at: undefined — never preserve stale trash state through
|
|
// an edit (carry-forward from Slice 5 review M3).
|
|
const item: Item = {
|
|
id: existing?.id ?? '', // SW fills in for add_item.
|
|
title,
|
|
type: 'login',
|
|
tags: existing?.tags ?? [],
|
|
favorite: existing?.favorite ?? false,
|
|
group: group || undefined,
|
|
notes: notes || undefined,
|
|
created: existing?.created ?? now,
|
|
modified: now,
|
|
trashed_at: undefined,
|
|
core,
|
|
sections: existing?.sections ?? [],
|
|
attachments: existing?.attachments ?? [],
|
|
field_history: existing?.field_history ?? {},
|
|
};
|
|
|
|
setState({ loading: true, error: null });
|
|
|
|
let resp;
|
|
if (mode === 'add') {
|
|
resp = await sendMessage({ type: 'add_item', item });
|
|
} else {
|
|
if (!state.selectedId) {
|
|
setState({ loading: false, error: 'Missing item id' });
|
|
return;
|
|
}
|
|
resp = await sendMessage({ type: 'update_item', id: state.selectedId, item });
|
|
}
|
|
|
|
if (resp.ok) {
|
|
const listResp = await sendMessage({ type: 'list_items' });
|
|
if (listResp.ok) {
|
|
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
|
|
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
|
|
} else {
|
|
navigate('list');
|
|
}
|
|
} else {
|
|
setState({ loading: false, error: resp.error });
|
|
}
|
|
}
|