From bc95b047a2052bb36b6e1fe5120bee6d6ad02ce8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 21:10:41 -0400 Subject: [PATCH] feat(ext/popup): Login detail view + coming-soon for other types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites item-detail.ts to dispatch on item.type: login gets the full detail view (url, username, masked password + copy, TOTP with 30s countdown, notes, group, autofill/edit/trash/back buttons). Non-login types get a coming-soon placeholder; those grow full UIs in later slices. Fixes Slice 4 review I1: the old autofill path sent a malformed fill_credentials payload ({ username, password } — no id/capturedTab). The new handler uses the (capturedTabId, capturedUrl) pair snapshotted at popup-open and calls fill_credentials with { id, capturedTabId, capturedUrl }, matching the SW's handler signature that enforces the M5 + TOCTOU checks. TOTP poll now calls get_totp on a 1s timer and renders the 30s countdown bar against expires_at. @ts-nocheck removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/popup/components/item-detail.ts | 222 +++++++++++++----- 1 file changed, 167 insertions(+), 55 deletions(-) diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index beba57f..54957ba 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -1,8 +1,12 @@ -// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires) -/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts. +/// Typed-item detail view — dispatches on `item.type`. Slice 6 delivers +/// full Login parity; all other types show a "coming soon" placeholder. +/// +/// Autofill uses the (capturedTabId, capturedUrl) pair snapshotted at +/// popup-open (see PopupState + router/popup-only.ts#handleFillCredentials) +/// so the SW can reject the fill if the tab navigated. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; +import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types'; let totpInterval: ReturnType | null = null; @@ -31,54 +35,63 @@ async function copyToClipboard(text: string): Promise { export function renderItemDetail(app: HTMLElement): void { const state = getState(); - const entry = state.selectedEntry; - const id = state.selectedId; - if (!entry || !id) { + const item = state.selectedItem; + if (!item) { navigate('list'); return; } stopTotpTimer(); + if (item.type === 'login') { + renderLogin(app, item); + } else { + renderComingSoon(app, item); + } +} + +// --- Login detail ------------------------------------------------------ + +function renderLogin(app: HTMLElement, item: Item): void { + const core = item.core as (LoginCore & { type: 'login' }); + const hasTotp = core.totp !== undefined; + let html = `
- ${escapeHtml(entry.name)} + ${escapeHtml(item.title)}
`; - // URL - if (entry.url) { + if (core.url) { html += `
url
-
${escapeHtml(entry.url)}
+
`; } - // Username - if (entry.username) { + if (core.username) { html += `
username
-
${escapeHtml(entry.username)}
+
${escapeHtml(core.username)}
`; } - // Password (masked by default) html += `
password
-
+
******** +
`; - // TOTP - if (entry.totp_secret) { + if (hasTotp) { html += `
totp
@@ -88,42 +101,46 @@ export function renderItemDetail(app: HTMLElement): void { `; } - // Notes - if (entry.notes) { + if (item.notes) { html += `
notes
-
${escapeHtml(entry.notes)}
+
${escapeHtml(item.notes)}
`; } - // Group - if (entry.group) { + if (item.group) { html += `
group
-
${escapeHtml(entry.group)}
+
${escapeHtml(item.group)}
`; } - // Metadata html += `
-
updated ${escapeHtml(entry.updated_at)}
+
modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}
+
+ `; + + html += ` +
+ + +
`; - // Key hints html += `
c copy user p copy pass - ${entry.totp_secret ? 't copy totp' : ''} + ${hasTotp ? 't copy totp' : ''} f autofill e edit - d delete + d trash
`; @@ -131,25 +148,65 @@ export function renderItemDetail(app: HTMLElement): void { // --- Password toggle --- let passwordVisible = false; - const passwordDisplay = document.getElementById('password-display')!; - const passwordVal = document.getElementById('password-val')!; - passwordVal?.addEventListener('click', () => { + const passwordDisplay = document.getElementById('password-display'); + const passwordVal = document.getElementById('password-val'); + const password = core.password ?? ''; + passwordVal?.addEventListener('click', (e) => { + // Ignore clicks originating on the copy button. + if ((e.target as HTMLElement).id === 'password-copy') return; passwordVisible = !passwordVisible; - passwordDisplay.textContent = passwordVisible ? entry.password : '********'; + if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********'; + }); + document.getElementById('password-copy')?.addEventListener('click', async (e) => { + e.stopPropagation(); + await copyToClipboard(password); }); - // --- Back button --- + if (core.username) { + document.getElementById('username-val')?.addEventListener('click', async () => { + await copyToClipboard(core.username ?? ''); + }); + } + document.getElementById('back-btn')?.addEventListener('click', goBack); + document.getElementById('fill-btn')?.addEventListener('click', async () => { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { + setState({ error: 'No active tab captured' }); + return; + } + const resp = await sendMessage({ + type: 'fill_credentials', + id: item.id, + capturedTabId, + capturedUrl, + }); + if (!resp.ok) { + setState({ error: resp.error }); + return; + } + window.close(); + }); + + document.getElementById('edit-btn')?.addEventListener('click', () => { + document.removeEventListener('keydown', handler); + stopTotpTimer(); + navigate('edit'); + }); + + document.getElementById('trash-btn')?.addEventListener('click', () => { + showDeleteConfirm(item.id, item.title, handler); + }); + // --- TOTP timer --- - if (entry.totp_secret) { - refreshTotp(id); - totpInterval = setInterval(() => refreshTotp(id), 1000); + if (hasTotp) { + void refreshTotp(item.id); + totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000); } // --- Keyboard shortcuts --- const handler = async (e: KeyboardEvent) => { - // Ignore if typing in an input. if ((e.target as HTMLElement).tagName === 'INPUT') return; switch (e.key) { @@ -159,27 +216,34 @@ export function renderItemDetail(app: HTMLElement): void { break; case 'c': - if (entry.username) await copyToClipboard(entry.username); + if (core.username) await copyToClipboard(core.username); break; case 'p': - await copyToClipboard(entry.password); + await copyToClipboard(password); break; case 't': - if (entry.totp_secret) { + if (hasTotp) { const codeEl = document.getElementById('totp-code'); if (codeEl) await copyToClipboard(codeEl.textContent ?? ''); } break; case 'f': { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { + setState({ error: 'No active tab captured' }); + break; + } const resp = await sendMessage({ type: 'fill_credentials', - username: entry.username ?? '', - password: entry.password, + id: item.id, + capturedTabId, + capturedUrl, }); if (!resp.ok) setState({ error: resp.error }); + else window.close(); break; } @@ -191,7 +255,7 @@ export function renderItemDetail(app: HTMLElement): void { case 'd': e.preventDefault(); - showDeleteConfirm(id, entry.name, handler); + showDeleteConfirm(item.id, item.title, handler); break; } }; @@ -199,40 +263,88 @@ export function renderItemDetail(app: HTMLElement): void { document.addEventListener('keydown', handler); } -async function refreshTotp(id: string): Promise { +async function refreshTotp(id: ItemId): Promise { const resp = await sendMessage({ type: 'get_totp', id }); if (resp.ok) { - const data = resp.data as { code: string; remaining_seconds: number }; + const data = resp.data as { code: string; expires_at: number }; const codeEl = document.getElementById('totp-code'); const barEl = document.getElementById('totp-bar-fill'); if (codeEl) codeEl.textContent = data.code; - if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`; + if (barEl) { + const now = Math.floor(Date.now() / 1000); + const remaining = Math.max(0, data.expires_at - now); + // Period is 30 by default; compute ratio against 30. + barEl.style.width = `${(remaining / 30) * 100}%`; + } + } + // Suppress unused warning; TotpConfig referenced for typing only below. + void ({} as TotpConfig); +} + +// --- Coming-soon for non-login types ----------------------------------- + +function renderComingSoon(app: HTMLElement, item: Item): void { + app.innerHTML = ` +
+ ${escapeHtml(item.title)} + +
+
+
${typeEmoji(item.type)}
+
${escapeHtml(item.type.replace('_', ' '))}
+

read/write for this type is coming in a later slice.

+

use the CLI for now.

+
+ `; + + document.getElementById('back-btn')?.addEventListener('click', goBack); + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handler); + goBack(); + } + }; + document.addEventListener('keydown', handler); +} + +function typeEmoji(t: Item['type']): string { + switch (t) { + case 'login': return '🔑'; + case 'secure_note': return '📝'; + case 'identity': return '🪪'; + case 'card': return '💳'; + case 'key': return '🗝'; + case 'document': return '📄'; + case 'totp': return '⏱'; } } +// --- Shared helpers ---------------------------------------------------- + function goBack(): void { stopTotpTimer(); - // Reload the entry list. - sendMessage({ type: 'list_entries' }).then(resp => { + // Reload the item list. + void sendMessage({ type: 'list_items' }).then(resp => { if (resp.ok) { - const data = resp.data as { entries: Array<[string, ManifestEntry]> }; + const data = resp.data as { items: Array<[ItemId, ManifestEntry]> }; navigate('list', { - entries: data.entries, + entries: data.items, selectedId: null, - selectedEntry: null, + selectedItem: null, }); } }); } -function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void { +function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void { const overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; overlay.innerHTML = `
-

Delete ${escapeHtml(name)}?

+

Trash ${escapeHtml(title)}?

- +
`; document.body.appendChild(overlay); @@ -244,7 +356,7 @@ function showDeleteConfirm(id: string, name: string, parentHandler: (e: Keyboard document.getElementById('confirm-delete')?.addEventListener('click', async () => { overlay.remove(); setState({ loading: true }); - const resp = await sendMessage({ type: 'delete_entry', id }); + const resp = await sendMessage({ type: 'delete_item', id }); if (resp.ok) { document.removeEventListener('keydown', parentHandler); stopTotpTimer();