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