docs: add credential capture implementation plan
5 tasks: types/messages, service worker handlers, capture content script with bar/toast prompts, settings popup view, and integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
845
docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md
Normal file
845
docs/superpowers/plans/2026-04-12-idfoto-credential-capture.md
Normal file
@@ -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<import('./types').IdfotoSettings> }
|
||||||
|
| { 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<IdfotoSettings> {
|
||||||
|
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<void> {
|
||||||
|
await chrome.storage.local.set({ settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBlacklist(): Promise<string[]> {
|
||||||
|
const data = await chrome.storage.local.get(['captureBlacklist']);
|
||||||
|
return data.captureBlacklist ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBlacklist(list: string[]): Promise<void> {
|
||||||
|
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<HTMLFormElement>();
|
||||||
|
const hookedButtons = new WeakSet<HTMLElement>();
|
||||||
|
|
||||||
|
// --- 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<HTMLInputElement>('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<HTMLInputElement>('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<void> {
|
||||||
|
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<HTMLFormElement>('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<HTMLElement>(
|
||||||
|
'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)}<br><span style="color:#8b949e;font-size:11px;">${escapeHtml(username)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, unknown> }).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<void> {
|
||||||
|
// 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 = `
|
||||||
|
<div class="pad" style="padding-top:12px;">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<span class="secondary" style="cursor:pointer;font-size:11px;" id="back-btn">← back</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="brand" style="margin-bottom:16px;">settings</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:16px;">
|
||||||
|
<div class="label">CREDENTIAL CAPTURE <span class="muted">(experimental)</span></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;">
|
||||||
|
<input type="checkbox" id="capture-toggle" ${settings.captureEnabled ? 'checked' : ''}>
|
||||||
|
auto-detect logins
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:16px;">
|
||||||
|
<div class="label">PROMPT STYLE</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||||
|
<button class="group-tab ${settings.captureStyle === 'bar' ? 'active' : ''}" data-style="bar">bar</button>
|
||||||
|
<button class="group-tab ${settings.captureStyle === 'toast' ? 'active' : ''}" data-style="toast">toast</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${blacklist.length > 0 ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label">BLACKLISTED SITES</div>
|
||||||
|
<div style="margin-top:6px;">
|
||||||
|
${blacklist.map(h => `
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:11px;">
|
||||||
|
<span class="secondary">${escapeHtml(h)}</span>
|
||||||
|
<span class="muted" style="cursor:pointer;" data-remove-host="${escapeHtml(h)}">✕</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// --- 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.
|
||||||
Reference in New Issue
Block a user