From 76bb61aa10f1ba8c613ce97e3c2424aadc78f72b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:12:14 -0400 Subject: [PATCH] feat(ext/popup): Login add/edit form on typed-item API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites item-form.ts for the typed-item Item shape. Login is the only editable type in Slice 6; other types fall through to coming-soon. Form fields: title (required) + url + username + password (with gen button backed by DEFAULT_PASSWORD_REQUEST) + totp (base32) + group + notes. TOTP base32 is decoded via shared/base32 and wrapped as a number[] into FieldValue-shape TotpConfig { secret, algorithm: sha1, digits: 6, period_seconds: 30, kind: 'totp' }. Decode failure sets state.error and aborts. Save constructs a full Item envelope (id, title, type, tags, favorite, group, notes, created, modified, trashed_at, core, sections, attachments, field_history). On edit we preserve the existing item's metadata but EXPLICITLY set trashed_at: undefined — carry-forward from Slice 5 review M3, so an edit cannot accidentally preserve stale trash state. @ts-nocheck removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-form.ts | 270 ++++++++++++++------ 1 file changed, 190 insertions(+), 80 deletions(-) diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 16fd9a9..b58dde1 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -1,47 +1,117 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) -/// Entry form — add or edit an entry. +/// 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 { Entry, ManifestEntry } from '../../shared/types'; +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.selectedEntry : null; + 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 entry' : 'edit entry'}
+
${mode === 'add' ? 'new login' : 'edit login'}
${state.error ? `
${escapeHtml(state.error)}
` : ''}
- - + +
- +
- +
- +
- - + +
- +
- +
@@ -52,92 +122,132 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { // --- Generate password --- document.getElementById('gen-btn')?.addEventListener('click', async () => { - const resp = await sendMessage({ type: 'generate_password', length: 24 }); + 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', () => { - if (mode === 'edit' && state.selectedId && state.selectedEntry) { - navigate('detail'); - } else { - navigate('list'); - } - }); + document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode)); // --- Save --- document.getElementById('save-btn')?.addEventListener('click', async () => { - const name = (document.getElementById('f-name') as HTMLInputElement).value.trim(); - const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined; - const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined; - const password = (document.getElementById('f-password') as HTMLInputElement).value; - const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined; - const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined; - const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined; - - if (!name) { - setState({ error: 'Name is required' }); - return; - } - if (!password) { - setState({ error: 'Password is required' }); - return; - } - - const now = new Date().toISOString(); - const entry: Entry = { - name, - url, - username, - password, - notes, - totp_secret, - group, - created_at: existing?.created_at ?? now, - updated_at: now, - }; - - setState({ loading: true, error: null }); - - let resp; - if (mode === 'add') { - resp = await sendMessage({ type: 'add_entry', entry }); - } else { - resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry }); - } - - if (resp.ok) { - // Refresh entries and go to list. - const listResp = await sendMessage({ type: 'list_entries' }); - if (listResp.ok) { - const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null }); - } else { - navigate('list'); - } - } else { - setState({ loading: false, error: resp.error }); - } + await saveLogin(mode, existing); }); // --- Escape to cancel --- const escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); - if (mode === 'edit' && state.selectedId && state.selectedEntry) { - navigate('detail'); - } else { - navigate('list'); - } + goBack(mode); } }; document.addEventListener('keydown', escHandler); - // Focus the name field. - (document.getElementById('f-name') as HTMLInputElement)?.focus(); + // 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'); + } +} + +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 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; + } + + 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 }); + } }