/// 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 = `
${escapeHtml(type.replace('_', ' '))}

editing ${escapeHtml(type)} items is coming in a later slice.

use the CLI for now.

`; 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 = `
${mode === 'add' ? 'new login' : 'edit login'}
${state.error ? `
${escapeHtml(state.error)}
` : ''}
`; // --- 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 { 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 }); } }