/// 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 { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types'; let totpInterval: ReturnType | null = null; function stopTotpTimer(): void { if (totpInterval !== null) { clearInterval(totpInterval); totpInterval = null; } } async function copyToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); } catch { // Fallback for older browsers. const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } } export function renderItemDetail(app: HTMLElement): void { const state = getState(); 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(item.title)}
`; if (core.url) { html += ` `; } if (core.username) { html += `
username
${escapeHtml(core.username)}
`; } html += `
password
********
`; if (hasTotp) { html += `
totp
------
`; } if (item.notes) { html += `
notes
${escapeHtml(item.notes)}
`; } if (item.group) { html += `
group
${escapeHtml(item.group)}
`; } html += `
modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}
`; html += `
`; html += `
c copy user p copy pass ${hasTotp ? 't copy totp' : ''} f autofill e edit d trash
`; app.innerHTML = html; // --- Password toggle --- let passwordVisible = false; 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; if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********'; }); document.getElementById('password-copy')?.addEventListener('click', async (e) => { e.stopPropagation(); await copyToClipboard(password); }); 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 (hasTotp) { void refreshTotp(item.id); totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000); } // --- Keyboard shortcuts --- const handler = async (e: KeyboardEvent) => { // Bail if the user is typing into any editable field — don't steal // printable keystrokes meant for an input/textarea/contenteditable element. const t = e.target; if (t instanceof HTMLElement) { const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; } switch (e.key) { case 'Escape': document.removeEventListener('keydown', handler); goBack(); break; case 'c': if (core.username) await copyToClipboard(core.username); break; case 'p': await copyToClipboard(password); break; case 't': 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', id: item.id, capturedTabId, capturedUrl, }); if (!resp.ok) setState({ error: resp.error }); else window.close(); break; } case 'e': document.removeEventListener('keydown', handler); stopTotpTimer(); navigate('edit'); break; case 'd': e.preventDefault(); showDeleteConfirm(item.id, item.title, handler); break; } }; document.addEventListener('keydown', handler); } async function refreshTotp(id: ItemId): Promise { const resp = await sendMessage({ type: 'get_totp', id }); if (resp.ok) { 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) { 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 item list. void sendMessage({ type: 'list_items' }).then(resp => { if (resp.ok) { const data = resp.data as { items: Array<[ItemId, ManifestEntry]> }; navigate('list', { entries: data.items, selectedId: null, selectedItem: null, }); } }); } function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void { const overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; overlay.innerHTML = `

Trash ${escapeHtml(title)}?

`; document.body.appendChild(overlay); document.getElementById('cancel-delete')?.addEventListener('click', () => { overlay.remove(); }); document.getElementById('confirm-delete')?.addEventListener('click', async () => { overlay.remove(); setState({ loading: true }); const resp = await sendMessage({ type: 'delete_item', id }); if (resp.ok) { document.removeEventListener('keydown', parentHandler); stopTotpTimer(); goBack(); } else { setState({ loading: false, error: resp.error }); } }); }