diff --git a/docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md b/docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md new file mode 100644 index 0000000..b22d45c --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md @@ -0,0 +1,845 @@ +# idfoto Credential Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add experimental credential capture that detects login form submissions and prompts the user to save or update credentials, with configurable bar/toast prompt style and per-site blacklist. + +**Architecture:** Content script hooks form submissions, captures credentials, asks the service worker to check against the manifest, then injects a prompt (bar or toast) into the page. New settings view in the popup for configuration. Feature is off by default. + +**Tech Stack:** TypeScript, Chrome extension APIs, DOM injection + +**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-credential-capture-design.md` + +--- + +## File Structure + +### New files + +``` +extension/src/content/capture.ts # Form submission detection + prompt injection +extension/src/popup/components/settings.ts # Settings view +``` + +### Modified files + +``` +extension/src/shared/types.ts # Add IdfotoSettings interface +extension/src/shared/messages.ts # Add new message types +extension/src/service-worker/index.ts # Handle new messages +extension/src/content/detector.ts # Import and init capture +extension/src/popup/popup.ts # Add 'settings' view +extension/src/popup/components/unlock.ts # Wire settings button to settings view +``` + +--- + +## Task 1: Add Types and Message Definitions + +**Files:** +- Modify: `extension/src/shared/types.ts` +- Modify: `extension/src/shared/messages.ts` + +- [ ] **Step 1: Add IdfotoSettings to types.ts** + +Add at the end of `extension/src/shared/types.ts`: + +```typescript +export interface IdfotoSettings { + captureEnabled: boolean; + captureStyle: 'bar' | 'toast'; +} + +export const DEFAULT_SETTINGS: IdfotoSettings = { + captureEnabled: false, + captureStyle: 'bar', +}; +``` + +- [ ] **Step 2: Add new message types to messages.ts** + +Add these to the `Request` union in `extension/src/shared/messages.ts`: + +```typescript +| { type: 'check_credential'; url: string; username: string; password: string } +| { type: 'blacklist_site'; hostname: string } +| { type: 'get_settings' } +| { type: 'update_settings'; settings: Partial } +| { type: 'get_blacklist' } +| { type: 'remove_blacklist'; hostname: string } +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/shared/types.ts extension/src/shared/messages.ts +git commit -m "feat: add settings and credential capture message types" +``` + +--- + +## Task 2: Service Worker Message Handlers + +**Files:** +- Modify: `extension/src/service-worker/index.ts` + +- [ ] **Step 1: Add settings and blacklist storage helpers** + +Add these helper functions to `extension/src/service-worker/index.ts`, after the existing storage helpers: + +```typescript +import type { IdfotoSettings } from '../shared/types'; +import { DEFAULT_SETTINGS } from '../shared/types'; + +async function loadSettings(): Promise { + const data = await chrome.storage.local.get(['settings']); + if (!data.settings) return { ...DEFAULT_SETTINGS }; + return { ...DEFAULT_SETTINGS, ...data.settings }; +} + +async function saveSettings(settings: IdfotoSettings): Promise { + await chrome.storage.local.set({ settings }); +} + +async function loadBlacklist(): Promise { + const data = await chrome.storage.local.get(['captureBlacklist']); + return data.captureBlacklist ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} +``` + +- [ ] **Step 2: Add message handlers** + +Add these cases to the `switch` statement in the message handler, before the `default` case: + +```typescript +case 'get_settings': { + const settings = await loadSettings(); + return { ok: true, data: settings }; +} + +case 'update_settings': { + const current = await loadSettings(); + const updated = { ...current, ...req.settings }; + await saveSettings(updated); + return { ok: true }; +} + +case 'get_blacklist': { + const list = await loadBlacklist(); + return { ok: true, data: { blacklist: list } }; +} + +case 'remove_blacklist': { + const list = await loadBlacklist(); + await saveBlacklist(list.filter(h => h !== req.hostname)); + return { ok: true }; +} + +case 'blacklist_site': { + const list = await loadBlacklist(); + if (!list.includes(req.hostname)) { + list.push(req.hostname); + await saveBlacklist(list); + } + return { ok: true }; +} + +case 'check_credential': { + // If vault is locked, skip + if (!masterKey || !gitHost || !manifest) { + return { ok: true, data: { action: 'skip' } }; + } + + // Check settings + const settings = await loadSettings(); + if (!settings.captureEnabled) { + return { ok: true, data: { action: 'skip' } }; + } + + // Check blacklist + let hostname: string; + try { + hostname = new URL(req.url).hostname; + } catch { + return { ok: true, data: { action: 'skip' } }; + } + + const blacklist = await loadBlacklist(); + if (blacklist.includes(hostname)) { + return { ok: true, data: { action: 'skip' } }; + } + + // Find matching entries by hostname + const matches = vault.findByUrl(manifest, req.url); + if (matches.length === 0) { + return { ok: true, data: { action: 'save' } }; + } + + // Check if any match has the same username + for (const [id, entry] of matches) { + if (entry.username === req.username) { + // Same username — check if password changed + const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, id); + if (fullEntry.password === req.password) { + // Exact match, already saved + return { ok: true, data: { action: 'skip' } }; + } else { + // Password changed + return { ok: true, data: { action: 'update', entryId: id, entryName: entry.name } }; + } + } + } + + // Different username on same site — new account + return { ok: true, data: { action: 'save' } }; +} +``` + +- [ ] **Step 3: Verify build** + +```bash +cd extension && bun run build +``` + +Expected: Compiles with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/service-worker/index.ts +git commit -m "feat: add settings, blacklist, and credential check handlers" +``` + +--- + +## Task 3: Credential Capture Content Script + +**Files:** +- Create: `extension/src/content/capture.ts` +- Modify: `extension/src/content/detector.ts` + +- [ ] **Step 1: Create capture.ts** + +Create `extension/src/content/capture.ts`: + +```typescript +/// Credential capture — detects login form submissions and prompts +/// the user to save or update credentials in the vault. +/// +/// This module hooks form submit events and submit button clicks, +/// captures username + password, asks the service worker whether to +/// save/update/skip, and injects a prompt (bar or toast) into the page. + +// --- Types --- + +interface CaptureResult { + action: 'save' | 'update' | 'skip'; + entryId?: string; + entryName?: string; +} + +type PromptStyle = 'bar' | 'toast'; + +// Track forms we've already hooked to avoid duplicates. +const hookedForms = new WeakSet(); +const hookedButtons = new WeakSet(); + +// --- Form Submission Detection --- + +/// Find the username field associated with a password field. +/// Same priority as detector.ts but inlined to avoid circular deps. +function findUsername(pwField: HTMLInputElement): string { + const form = pwField.closest('form'); + const scope = form ?? document; + const inputs = scope.querySelectorAll('input'); + + // autocomplete="username" + for (const input of inputs) { + if (input !== pwField && input.autocomplete === 'username' && input.value) return input.value; + } + // autocomplete="email" + for (const input of inputs) { + if (input !== pwField && input.autocomplete === 'email' && input.value) return input.value; + } + // type="email" + for (const input of inputs) { + if (input !== pwField && input.type === 'email' && input.value) return input.value; + } + // name/id pattern + const pattern = /user|email|login|account/i; + for (const input of inputs) { + if (input === pwField || input.type === 'hidden' || input.type === 'password') continue; + if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value; + } + // Nearest preceding visible text input + const allInputs = Array.from(inputs); + const pwIndex = allInputs.indexOf(pwField); + for (let i = pwIndex - 1; i >= 0; i--) { + const input = allInputs[i]; + if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue; + if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value; + } + + return ''; +} + +/// Capture credentials from a form that contains a password field. +function captureFromForm(form: HTMLFormElement): { username: string; password: string } | null { + const pwField = form.querySelector('input[type="password"]'); + if (!pwField || !pwField.value) return null; + + const username = findUsername(pwField); + return { username, password: pwField.value }; +} + +/// Handle a form submission — capture credentials and check with service worker. +async function handleSubmission(form: HTMLFormElement): Promise { + const creds = captureFromForm(form); + if (!creds || !creds.password) return; + + const url = window.location.href; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'check_credential', + url, + username: creds.username, + password: creds.password, + }); + + if (!response?.ok) return; + const result = response.data as CaptureResult; + if (result.action === 'skip') return; + + // Get the prompt style from settings + const settingsResp = await chrome.runtime.sendMessage({ type: 'get_settings' }); + const style: PromptStyle = settingsResp?.ok ? (settingsResp.data as { captureStyle: PromptStyle }).captureStyle : 'bar'; + + showPrompt(style, result, url, creds.username, creds.password); + } catch { + // Extension not available or vault locked — silently skip + } +} + +/// Hook form submit events and submit button clicks. +export function hookForms(): void { + const forms = document.querySelectorAll('form'); + + for (const form of forms) { + // Only hook forms that contain a password field. + if (!form.querySelector('input[type="password"]')) continue; + if (hookedForms.has(form)) continue; + hookedForms.add(form); + + // Hook form submit event. + form.addEventListener('submit', () => { + handleSubmission(form); + }); + + // Hook submit button clicks (some sites don't use form submit). + const submitBtns = form.querySelectorAll( + 'button[type="submit"], input[type="submit"], button:not([type])' + ); + for (const btn of submitBtns) { + if (hookedButtons.has(btn)) continue; + hookedButtons.add(btn); + btn.addEventListener('click', () => { + handleSubmission(form); + }); + } + } +} + +// --- Prompt UI --- + +/// Remove any existing idfoto prompt from the page. +function removePrompt(): void { + document.getElementById('idfoto-capture-prompt')?.remove(); +} + +/// Show a save/update prompt. +function showPrompt( + style: PromptStyle, + result: CaptureResult, + url: string, + username: string, + password: string, +): void { + removePrompt(); + + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + hostname = url; + } + + const isUpdate = result.action === 'update'; + const actionLabel = isUpdate ? 'Update' : 'Save'; + const message = isUpdate + ? `Update password for ${hostname}?` + : `Save login for ${hostname}?`; + + const container = document.createElement('div'); + container.id = 'idfoto-capture-prompt'; + + // Common styles + const baseStyles = ` + font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace; + font-size: 13px; + color: #c9d1d9; + background: #161b22; + border: 1px solid #30363d; + z-index: 2147483647; + box-sizing: border-box; + `; + + if (style === 'bar') { + container.style.cssText = ` + ${baseStyles} + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 10px 16px; + display: flex; + align-items: center; + gap: 12px; + border-top: none; + border-left: none; + border-right: none; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + transform: translateY(-100%); + transition: transform 0.2s ease-out; + `; + // Slide in after a frame + requestAnimationFrame(() => { + requestAnimationFrame(() => { + container.style.transform = 'translateY(0)'; + }); + }); + } else { + container.style.cssText = ` + ${baseStyles} + position: fixed; + bottom: 16px; + right: 16px; + padding: 12px 16px; + border-radius: 4px; + min-width: 260px; + max-width: 340px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + opacity: 0; + transition: opacity 0.2s ease-out; + `; + requestAnimationFrame(() => { + container.style.opacity = '1'; + }); + + // Auto-dismiss after 15 seconds. + setTimeout(() => { + if (container.isConnected) { + container.style.opacity = '0'; + setTimeout(removePrompt, 200); + } + }, 15000); + } + + // Brand label + const brand = document.createElement('span'); + brand.textContent = 'idfoto'; + brand.style.cssText = 'color: #58a6ff; font-weight: normal; letter-spacing: 1px;'; + + // Message text + const msg = document.createElement('span'); + msg.style.cssText = 'flex: 1;'; + if (style === 'bar') { + msg.textContent = `${message} ${username ? `(${username})` : ''}`; + } else { + msg.innerHTML = `${escapeHtml(message)}
${escapeHtml(username)}`; + } + + // Buttons + const btnStyle = ` + font-family: inherit; + font-size: 11px; + padding: 4px 12px; + border-radius: 2px; + cursor: pointer; + border: none; + `; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = actionLabel; + saveBtn.style.cssText = `${btnStyle} background: #1f6feb; color: #fff;`; + + const neverBtn = document.createElement('button'); + neverBtn.textContent = 'Never'; + neverBtn.style.cssText = `${btnStyle} background: #21262d; color: #8b949e; border: 1px solid #30363d;`; + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = `${btnStyle} background: transparent; color: #484f58; font-size: 14px; padding: 4px 8px;`; + + // Event handlers + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.textContent = '...'; + + try { + if (isUpdate && result.entryId) { + // Fetch existing entry, update password + const getResp = await chrome.runtime.sendMessage({ type: 'get_entry', id: result.entryId }); + if (getResp?.ok) { + const existing = (getResp.data as { entry: Record }).entry; + await chrome.runtime.sendMessage({ + type: 'update_entry', + id: result.entryId, + entry: { ...existing, password }, + }); + } + } else { + await chrome.runtime.sendMessage({ + type: 'add_entry', + entry: { + name: hostname, + url, + username: username || undefined, + password, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + }); + } + // Brief confirmation + msg.textContent = isUpdate ? '✓ Updated' : '✓ Saved'; + saveBtn.remove(); + neverBtn.remove(); + setTimeout(removePrompt, 1500); + } catch { + saveBtn.textContent = 'Error'; + setTimeout(removePrompt, 2000); + } + }); + + neverBtn.addEventListener('click', async () => { + await chrome.runtime.sendMessage({ type: 'blacklist_site', hostname }); + removePrompt(); + }); + + closeBtn.addEventListener('click', removePrompt); + + // Assemble + if (style === 'bar') { + container.appendChild(brand); + container.appendChild(msg); + container.appendChild(saveBtn); + container.appendChild(neverBtn); + container.appendChild(closeBtn); + } else { + const header = document.createElement('div'); + header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'; + header.appendChild(brand); + header.appendChild(closeBtn); + + const body = document.createElement('div'); + body.style.cssText = 'margin-bottom:10px;'; + body.appendChild(msg); + + const actions = document.createElement('div'); + actions.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;'; + actions.appendChild(neverBtn); + actions.appendChild(saveBtn); + + container.appendChild(header); + container.appendChild(body); + container.appendChild(actions); + } + + document.body.appendChild(container); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 2: Import and init capture in detector.ts** + +Add to the top of `extension/src/content/detector.ts`, after the existing imports: + +```typescript +import { hookForms } from './capture'; +``` + +Add `hookForms()` calls in two places: + +After the `scan()` call near the bottom (line ~91): +```typescript +// Initial scan. +scan(); +hookForms(); +``` + +Inside the MutationObserver callback (line ~95): +```typescript +const observer = new MutationObserver(() => { + scan(); + hookForms(); +}); +``` + +- [ ] **Step 3: Build and verify** + +```bash +cd extension && bun run build +``` + +Expected: Compiles with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/content/capture.ts extension/src/content/detector.ts +git commit -m "feat: add credential capture with bar/toast prompts" +``` + +--- + +## Task 4: Settings View in Popup + +**Files:** +- Create: `extension/src/popup/components/settings.ts` +- Modify: `extension/src/popup/popup.ts` +- Modify: `extension/src/popup/components/unlock.ts` + +- [ ] **Step 1: Create settings.ts** + +Create `extension/src/popup/components/settings.ts`: + +```typescript +/// Settings view — configure credential capture and manage blacklist. + +import { setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { IdfotoSettings } from '../../shared/types'; + +export async function renderSettings(app: HTMLElement): Promise { + // Load current settings and blacklist in parallel. + const [settingsResp, blacklistResp] = await Promise.all([ + sendMessage({ type: 'get_settings' }), + sendMessage({ type: 'get_blacklist' }), + ]); + + const settings: IdfotoSettings = settingsResp.ok + ? settingsResp.data as IdfotoSettings + : { captureEnabled: false, captureStyle: 'bar' }; + + const blacklist: string[] = blacklistResp.ok + ? (blacklistResp.data as { blacklist: string[] }).blacklist + : []; + + app.innerHTML = ` +
+
+ ← back +
+ +
settings
+ +
+
CREDENTIAL CAPTURE (experimental)
+
+ +
+
+ +
+
PROMPT STYLE
+
+ + +
+
+ + ${blacklist.length > 0 ? ` +
+
BLACKLISTED SITES
+
+ ${blacklist.map(h => ` +
+ ${escapeHtml(h)} + +
+ `).join('')} +
+
+ ` : ''} +
+ `; + + // --- Event listeners --- + + document.getElementById('back-btn')?.addEventListener('click', () => { + navigate('locked'); + }); + + document.getElementById('capture-toggle')?.addEventListener('change', async (e) => { + const enabled = (e.target as HTMLInputElement).checked; + await sendMessage({ type: 'update_settings', settings: { captureEnabled: enabled } }); + }); + + document.querySelectorAll('[data-style]').forEach(btn => { + btn.addEventListener('click', async () => { + const style = (btn as HTMLElement).dataset.style as 'bar' | 'toast'; + await sendMessage({ type: 'update_settings', settings: { captureStyle: style } }); + // Re-render to update active state. + renderSettings(app); + }); + }); + + document.querySelectorAll('[data-remove-host]').forEach(btn => { + btn.addEventListener('click', async () => { + const hostname = (btn as HTMLElement).dataset.removeHost!; + await sendMessage({ type: 'remove_blacklist', hostname }); + // Re-render to remove from list. + renderSettings(app); + }); + }); +} +``` + +- [ ] **Step 2: Add 'settings' view to popup.ts** + +In `extension/src/popup/popup.ts`: + +Add the import at the top with the other component imports: + +```typescript +import { renderSettings } from './components/settings'; +``` + +Update the `View` type: + +```typescript +export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; +``` + +Add the case to the `render()` switch: + +```typescript +case 'settings': + renderSettings(app); + break; +``` + +- [ ] **Step 3: Wire settings button in unlock.ts** + +In `extension/src/popup/components/unlock.ts`, change the settings button handler (line ~55): + +From: +```typescript +settingsBtn?.addEventListener('click', () => navigate('setup')); +``` + +To: +```typescript +settingsBtn?.addEventListener('click', () => navigate('settings')); +``` + +- [ ] **Step 4: Build and verify** + +```bash +cd extension && bun run build +``` + +Expected: Compiles with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/popup/components/settings.ts extension/src/popup/popup.ts extension/src/popup/components/unlock.ts +git commit -m "feat: add settings view with capture toggle and blacklist management" +``` + +--- + +## Task 5: Build and Manual Test + +**Files:** None (integration testing) + +- [ ] **Step 1: Full build** + +```bash +cd extension && bun run build +``` + +Expected: Compiles with no errors (warnings about WASM size are fine). + +- [ ] **Step 2: Reload extension in Chrome** + +Open `chrome://extensions/`, reload the unpacked extension. + +- [ ] **Step 3: Test settings view** + +1. Open popup → click "settings" from unlock screen +2. Verify toggle for "auto-detect logins" (should be off by default) +3. Toggle it on +4. Verify bar/toast style selector works +5. Go back to unlock screen + +- [ ] **Step 4: Test credential capture (bar mode)** + +1. Enable capture in settings, set style to "bar" +2. Unlock the vault +3. Navigate to a login page (e.g. GitHub) +4. Enter credentials and submit the form +5. Verify: notification bar slides down from top with "Save login for github.com?" +6. Click "Save" — verify entry appears in vault +7. Submit same credentials again — verify no prompt (already saved) +8. Change password and submit — verify "Update password?" prompt + +- [ ] **Step 5: Test credential capture (toast mode)** + +1. Change style to "toast" in settings +2. Submit a login form on a new site +3. Verify: floating toast appears in bottom-right +4. Verify: auto-dismisses after ~15 seconds if ignored + +- [ ] **Step 6: Test blacklist** + +1. Click "Never" on a capture prompt +2. Submit login on same site again — verify no prompt +3. Open settings — verify site appears in blacklist +4. Remove site from blacklist — verify it's gone + +- [ ] **Step 7: Fix any issues found** + +- [ ] **Step 8: Final commit** + +```bash +git add -A +git commit -m "feat: complete credential capture feature" +``` + +--- + +## Task Summary + +| Task | Description | Dependencies | +|------|-------------|--------------| +| 1 | Add types and message definitions | None | +| 2 | Service worker message handlers | Task 1 | +| 3 | Capture content script + detector integration | Task 1 | +| 4 | Settings view in popup | Task 1 | +| 5 | Build and manual test | All | + +Tasks 2, 3, and 4 can run in parallel after Task 1. Task 5 is final integration.