diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index e00f121..45ead0b 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -1,374 +1,33 @@ -/// 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. +/// Typed-item detail view dispatcher. Each type's renderDetail lives in +/// its own module under ./types/. Document stays "coming soon" until γ. -import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types'; +import { navigate } from '../popup'; +import type { Item } from '../../shared/types'; +import { getState } from '../popup'; +import * as login from './types/login'; -let totpInterval: ReturnType | null = null; +export async function renderItemDetail(app: HTMLElement): Promise { + const item = getState().selectedItem; + if (!item) { navigate('list'); return; } -function stopTotpTimer(): void { - if (totpInterval !== null) { - clearInterval(totpInterval); - totpInterval = null; + switch (item.type) { + case 'login': return login.renderDetail(app, item); + case 'secure_note': + case 'identity': + case 'card': + case 'key': + case 'totp': + case 'document': return renderComingSoon(app, item); } } -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.

+
+
${item.title}
+

The ${item.type} item type is not editable in the extension yet.

+
`; - - 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 }); - } - }); + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); } diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index 58b68c1..e22442e 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -1,281 +1,33 @@ -/// 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.) +/// Typed-item add/edit form dispatcher. Each type's renderForm lives in +/// its own module under ./types/. Document stays "coming soon" until γ. -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'; -} +import { navigate, getState } from '../popup'; +import type { Item, ItemType } from '../../shared/types'; +import * as login from './types/login'; export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; + const type: ItemType = existing?.type ?? state.newType ?? 'login'; - // Determine the type we're editing/creating. Add defaults to login. - const type: ItemType = existing?.type ?? 'login'; - - if (!isEditableType(type)) { - renderComingSoon(app, type); - return; + switch (type) { + case 'login': return login.renderForm(app, mode, existing); + case 'secure_note': + case 'identity': + case 'card': + case 'key': + case 'totp': + case 'document': return renderComingSoon(app, type); } - - 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.

-
- -
+
${type.replace('_', ' ')}
+

Editing ${type} items is not available yet.

+
`; 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 }); - } } diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts new file mode 100644 index 0000000..6cf4db7 --- /dev/null +++ b/extension/src/popup/components/types/login.ts @@ -0,0 +1,283 @@ +/// Login type detail + form. Reference implementation for the shared +/// field helpers introduced in Slice 2. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup'; +import type { Item, ItemId, LoginCore, ManifestEntry, TotpConfig } from '../../../shared/types'; +import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types'; +import { base32Decode, base32Encode } from '../../../shared/base32'; +import { + renderRow, + renderConcealedRow, + renderSignatureBlock, + wireFieldHandlers, +} from '../fields'; + +// ---------------------------------------------------------------------- +// Detail view +// ---------------------------------------------------------------------- + +export async function renderDetail(app: HTMLElement, item: Item): Promise { + if (item.core.type !== 'login') return; + const core = item.core as LoginCore & { type: 'login' }; + const password = core.password ?? ''; + const username = core.username ?? ''; + const url = core.url ?? ''; + const hasTotp = core.totp !== undefined; + + const sigInner = ` +
+
${escapeHtml(item.title)}
+ ${url ? `open ↗` : ''} +
+ `; + + app.innerHTML = ` +
+
+ ${renderSignatureBlock({ accent: 'blue', children: sigInner })} +
+ ${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''} + ${renderConcealedRow({ id: 'login-password', label: 'password', value: password })} + ${url ? renderRow({ label: 'url', value: url, href: url }) : ''} + ${hasTotp ? ` +
+ totp + + +
+ ` : ''} + ${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''} +
+ + + + +
+
+ `; + + wireFieldHandlers(app); + + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit')); + document.getElementById('trash-btn')?.addEventListener('click', async () => { + if (!confirm(`Move "${item.title}" to trash?`)) return; + const resp = await sendMessage({ type: 'delete_item', id: item.id }); + if (!resp.ok) { setState({ error: resp.error }); return; } + 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'); + }); + 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 }); + else window.close(); + }); + + if (hasTotp) startTotpTicker(item.id); + + const handler = (e: KeyboardEvent) => { + const t = e.target; + if (t instanceof HTMLElement) { + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; + } + if (e.key === 'Escape') { + document.removeEventListener('keydown', handler); + stopTotpTicker(); + navigate('list'); + } + }; + document.addEventListener('keydown', handler); +} + +// ---------------------------------------------------------------------- +// TOTP ticker +// ---------------------------------------------------------------------- + +let totpTickerId: ReturnType | null = null; +function stopTotpTicker(): void { + if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } +} +function startTotpTicker(id: ItemId): void { + stopTotpTicker(); + const tick = async () => { + const r = await sendMessage({ type: 'get_totp', id }); + if (!r.ok) return; + const { code, expires_at } = r.data as { code: string; expires_at: number }; + const codeEl = document.getElementById('totp-code'); + const cdEl = document.getElementById('totp-countdown'); + if (codeEl) codeEl.textContent = code; + if (cdEl) cdEl.textContent = `${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s`; + }; + void tick(); + totpTickerId = setInterval(() => void tick(), 1000); +} + +// ---------------------------------------------------------------------- +// Form (add / edit) +// ---------------------------------------------------------------------- + +export function renderForm(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 = existingCore?.totp ? base32Encode(new Uint8Array(existingCore.totp.secret)) : ''; + const group = existing?.group ?? ''; + const notes = existing?.notes ?? ''; + + app.innerHTML = ` +
+
${mode === 'add' ? 'new login' : 'edit login'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+ `; + + 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 pw = document.getElementById('f-password') as HTMLInputElement; + pw.value = data.password; + pw.type = 'text'; + } else setState({ error: resp.error }); + }); + + document.getElementById('cancel-btn')?.addEventListener('click', () => { + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + }); + + document.getElementById('save-btn')?.addEventListener('click', async () => { + await saveLogin(mode, existing); + }); + + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + setState({ error: null }); + navigate(mode === 'edit' ? 'detail' : 'list'); + } + }; + document.addEventListener('keydown', escHandler); + + (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); +} + +function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } { + if (!raw) return { ok: true, value: '' }; + const trimmed = raw.trim(); + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const u = new URL(candidate); + 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, + }; + + const item: Item = { + id: existing?.id ?? '', + 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 }); + + const resp = mode === 'add' + ? await sendMessage({ type: 'add_item', item }) + : 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 }); + } +} diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index c562f5c..2f8131c 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -38,6 +38,7 @@ export interface PopupState { // to the content script. See router/popup-only.ts#handleFillCredentials. capturedTabId: number | null; capturedUrl: string; + newType: import('../shared/types').ItemType | null; } let currentState: PopupState = { @@ -52,6 +53,7 @@ let currentState: PopupState = { loading: false, capturedTabId: null, capturedUrl: '', + newType: null, }; export function getState(): PopupState {