diff --git a/extension/src/popup/components/entry-detail.ts b/extension/src/popup/components/entry-detail.ts new file mode 100644 index 0000000..be06bdf --- /dev/null +++ b/extension/src/popup/components/entry-detail.ts @@ -0,0 +1,255 @@ +/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { ManifestEntry } from '../../shared/types'; + +let totpInterval: ReturnType | null = null; + +function stopTotpTimer(): void { + if (totpInterval !== null) { + clearInterval(totpInterval); + totpInterval = null; + } +} + +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + // Fallback for older browsers. + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } +} + +export function renderEntryDetail(app: HTMLElement): void { + const state = getState(); + const entry = state.selectedEntry; + const id = state.selectedId; + if (!entry || !id) { + navigate('list'); + return; + } + + stopTotpTimer(); + + let html = ` +
+ ${escapeHtml(entry.name)} + +
+ `; + + // URL + if (entry.url) { + html += ` +
+
url
+
${escapeHtml(entry.url)}
+
+ `; + } + + // Username + if (entry.username) { + html += ` +
+
username
+
${escapeHtml(entry.username)}
+
+ `; + } + + // Password (masked by default) + html += ` +
+
password
+
+ ******** +
+
+ `; + + // TOTP + if (entry.totp_secret) { + html += ` +
+
totp
+
------
+
+
+ `; + } + + // Notes + if (entry.notes) { + html += ` +
+
notes
+
${escapeHtml(entry.notes)}
+
+ `; + } + + // Group + if (entry.group) { + html += ` +
+
group
+
${escapeHtml(entry.group)}
+
+ `; + } + + // Metadata + html += ` +
+
updated ${escapeHtml(entry.updated_at)}
+
+ `; + + // Key hints + html += ` +
+ c copy user + p copy pass + ${entry.totp_secret ? 't copy totp' : ''} + f autofill + e edit + d delete +
+ `; + + app.innerHTML = html; + + // --- Password toggle --- + let passwordVisible = false; + const passwordDisplay = document.getElementById('password-display')!; + const passwordVal = document.getElementById('password-val')!; + passwordVal?.addEventListener('click', () => { + passwordVisible = !passwordVisible; + passwordDisplay.textContent = passwordVisible ? entry.password : '********'; + }); + + // --- Back button --- + document.getElementById('back-btn')?.addEventListener('click', goBack); + + // --- TOTP timer --- + if (entry.totp_secret) { + refreshTotp(id); + totpInterval = setInterval(() => refreshTotp(id), 1000); + } + + // --- Keyboard shortcuts --- + const handler = async (e: KeyboardEvent) => { + // Ignore if typing in an input. + if ((e.target as HTMLElement).tagName === 'INPUT') return; + + switch (e.key) { + case 'Escape': + document.removeEventListener('keydown', handler); + goBack(); + break; + + case 'c': + if (entry.username) await copyToClipboard(entry.username); + break; + + case 'p': + await copyToClipboard(entry.password); + break; + + case 't': + if (entry.totp_secret) { + const codeEl = document.getElementById('totp-code'); + if (codeEl) await copyToClipboard(codeEl.textContent ?? ''); + } + break; + + case 'f': { + const resp = await sendMessage({ + type: 'fill_credentials', + username: entry.username ?? '', + password: entry.password, + }); + if (!resp.ok) setState({ error: resp.error }); + break; + } + + case 'e': + document.removeEventListener('keydown', handler); + stopTotpTimer(); + navigate('edit'); + break; + + case 'd': + e.preventDefault(); + showDeleteConfirm(id, entry.name, handler); + break; + } + }; + + document.addEventListener('keydown', handler); +} + +async function refreshTotp(id: string): Promise { + const resp = await sendMessage({ type: 'get_totp', id }); + if (resp.ok) { + const data = resp.data as { code: string; remaining_seconds: number }; + const codeEl = document.getElementById('totp-code'); + const barEl = document.getElementById('totp-bar-fill'); + if (codeEl) codeEl.textContent = data.code; + if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`; + } +} + +function goBack(): void { + stopTotpTimer(); + // Reload the entry list. + sendMessage({ type: 'list_entries' }).then(resp => { + if (resp.ok) { + const data = resp.data as { entries: Array<[string, ManifestEntry]> }; + navigate('list', { + entries: data.entries, + selectedId: null, + selectedEntry: null, + }); + } + }); +} + +function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.innerHTML = ` +
+

Delete ${escapeHtml(name)}?

+ + +
+ `; + 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_entry', id }); + if (resp.ok) { + document.removeEventListener('keydown', parentHandler); + stopTotpTimer(); + goBack(); + } else { + setState({ loading: false, error: resp.error }); + } + }); +} diff --git a/extension/src/popup/components/entry-form.ts b/extension/src/popup/components/entry-form.ts new file mode 100644 index 0000000..812da91 --- /dev/null +++ b/extension/src/popup/components/entry-form.ts @@ -0,0 +1,142 @@ +/// Entry form — add or edit an entry. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { Entry, ManifestEntry } from '../../shared/types'; + +export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void { + const state = getState(); + const existing = mode === 'edit' ? state.selectedEntry : null; + + app.innerHTML = ` +
+
${mode === 'add' ? 'new entry' : 'edit entry'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + // --- Generate password --- + document.getElementById('gen-btn')?.addEventListener('click', async () => { + const resp = await sendMessage({ type: 'generate_password', length: 24 }); + 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. + } + }); + + // --- Cancel --- + document.getElementById('cancel-btn')?.addEventListener('click', () => { + if (mode === 'edit' && state.selectedId && state.selectedEntry) { + navigate('detail'); + } else { + navigate('list'); + } + }); + + // --- Save --- + document.getElementById('save-btn')?.addEventListener('click', async () => { + const name = (document.getElementById('f-name') as HTMLInputElement).value.trim(); + const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined; + const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined; + const password = (document.getElementById('f-password') as HTMLInputElement).value; + const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined; + const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined; + const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined; + + if (!name) { + setState({ error: 'Name is required' }); + return; + } + if (!password) { + setState({ error: 'Password is required' }); + return; + } + + const now = new Date().toISOString(); + const entry: Entry = { + name, + url, + username, + password, + notes, + totp_secret, + group, + created_at: existing?.created_at ?? now, + updated_at: now, + }; + + setState({ loading: true, error: null }); + + let resp; + if (mode === 'add') { + resp = await sendMessage({ type: 'add_entry', entry }); + } else { + resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry }); + } + + if (resp.ok) { + // Refresh entries and go to list. + const listResp = await sendMessage({ type: 'list_entries' }); + if (listResp.ok) { + const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; + navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null }); + } else { + navigate('list'); + } + } else { + setState({ loading: false, error: resp.error }); + } + }); + + // --- Escape to cancel --- + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + if (mode === 'edit' && state.selectedId && state.selectedEntry) { + navigate('detail'); + } else { + navigate('list'); + } + } + }; + document.addEventListener('keydown', escHandler); + + // Focus the name field. + (document.getElementById('f-name') as HTMLInputElement)?.focus(); +} diff --git a/extension/src/popup/components/entry-list.ts b/extension/src/popup/components/entry-list.ts new file mode 100644 index 0000000..9bc6ee0 --- /dev/null +++ b/extension/src/popup/components/entry-list.ts @@ -0,0 +1,173 @@ +/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { ManifestEntry } from '../../shared/types'; + +/// Extract the domain from a URL for display. +function domainOf(url: string | undefined): string { + if (!url) return ''; + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +/// Derive unique group names from the current entries. +function getGroups(entries: Array<[string, ManifestEntry]>): string[] { + const groups = new Set(); + for (const [, e] of entries) { + if (e.group) groups.add(e.group); + } + return Array.from(groups).sort(); +} + +export function renderEntryList(app: HTMLElement): void { + const state = getState(); + const groups = getGroups(state.entries); + + // Filter entries by active group (already filtered if group was sent to service worker, + // but we also support client-side filtering for instant response). + let filtered = state.entries; + if (state.activeGroup) { + const g = state.activeGroup.toLowerCase(); + filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g); + } + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + filtered = filtered.filter(([, e]) => { + if (e.name.toLowerCase().includes(q)) return true; + if (e.url?.toLowerCase().includes(q)) return true; + if (e.username?.toLowerCase().includes(q)) return true; + return false; + }); + } + + // Sort by name. + filtered.sort((a, b) => a[1].name.localeCompare(b[1].name)); + + const groupTabsHtml = groups.length > 0 + ? `
+ + ${groups.map(g => + `` + ).join('')} +
` + : ''; + + const entriesHtml = filtered.length > 0 + ? filtered.map(([id, e], i) => ` +
+ ${escapeHtml(e.name)} + +
+ `).join('') + : '
no entries
'; + + app.innerHTML = ` + + ${groupTabsHtml} +
+ ${entriesHtml} +
+
+ / search + + add + ↑↓ nav + Enter open +
+ `; + + // --- Event listeners --- + + const searchInput = document.getElementById('search-input') as HTMLInputElement; + searchInput?.addEventListener('input', () => { + setState({ searchQuery: searchInput.value, selectedIndex: 0 }); + }); + + // Group tab clicks. + const groupTabs = app.querySelectorAll('.group-tab'); + groupTabs.forEach(tab => { + tab.addEventListener('click', () => { + const group = (tab as HTMLElement).dataset.group || null; + setState({ activeGroup: group, selectedIndex: 0 }); + }); + }); + + // Entry row clicks. + const rows = app.querySelectorAll('.entry-row'); + rows.forEach(row => { + row.addEventListener('click', async () => { + const id = (row as HTMLElement).dataset.id!; + await openEntry(id); + }); + }); + + // Keyboard navigation. + document.addEventListener('keydown', handleListKeydown); + + // Focus search on / key (unless already focused). + searchInput?.focus(); +} + +async function openEntry(id: string): Promise { + setState({ loading: true }); + const resp = await sendMessage({ type: 'get_entry', id }); + if (resp.ok) { + const data = resp.data as { entry: import('../../shared/types').Entry }; + navigate('detail', { + selectedId: id, + selectedEntry: data.entry, + }); + } else { + setState({ loading: false, error: resp.error }); + } +} + +function handleListKeydown(e: KeyboardEvent): void { + const state = getState(); + const target = e.target as HTMLElement; + const isSearch = target.id === 'search-input'; + + if (e.key === '/' && !isSearch) { + e.preventDefault(); + (document.getElementById('search-input') as HTMLInputElement)?.focus(); + return; + } + + if (e.key === '+' && !isSearch) { + e.preventDefault(); + navigate('add'); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const max = state.entries.length - 1; + setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) }); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) }); + return; + } + + if (e.key === 'Enter' && !isSearch) { + e.preventDefault(); + const filtered = state.entries; + if (filtered[state.selectedIndex]) { + openEntry(filtered[state.selectedIndex][0]); + } + return; + } + + if (e.key === 'Escape') { + // Remove listener to avoid stacking. + document.removeEventListener('keydown', handleListKeydown); + return; + } +} diff --git a/extension/src/popup/components/setup-wizard.ts b/extension/src/popup/components/setup-wizard.ts new file mode 100644 index 0000000..934412e --- /dev/null +++ b/extension/src/popup/components/setup-wizard.ts @@ -0,0 +1,259 @@ +/// Setup wizard — 3-step flow: host config, image upload, test unlock. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { VaultConfig, ManifestEntry } from '../../shared/types'; + +let wizardStep = 0; +let wizardConfig: Partial = { + hostType: 'gitea', +}; +let wizardImageBase64: string | null = null; + +export function renderSetupWizard(app: HTMLElement): void { + const state = getState(); + + // Progress bar. + const progressHtml = ` +
+
+
+
+
+ `; + + let stepHtml = ''; + + switch (wizardStep) { + case 0: + stepHtml = renderStep0(); + break; + case 1: + stepHtml = renderStep1(); + break; + case 2: + stepHtml = renderStep2(state); + break; + } + + app.innerHTML = ` +
+
idfoto setup
+ ${progressHtml} + ${stepHtml} +
+ `; + + // Attach event listeners after rendering. + switch (wizardStep) { + case 0: attachStep0Listeners(); break; + case 1: attachStep1Listeners(); break; + case 2: attachStep2Listeners(); break; + } +} + +// --- Step 0: Host configuration --- + +function renderStep0(): string { + return ` +
+

git host

+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ `; +} + +function attachStep0Listeners(): void { + // Host type toggle. + document.querySelectorAll('.toggle-group button').forEach(btn => { + btn.addEventListener('click', () => { + const hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; + wizardConfig.hostType = hostType; + const urlGroup = document.getElementById('host-url-group'); + if (urlGroup) { + urlGroup.style.display = hostType === 'github' ? 'none' : ''; + } + document.querySelectorAll('.toggle-group button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + + document.getElementById('next-btn')?.addEventListener('click', () => { + const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim(); + const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim(); + const hostUrl = wizardConfig.hostType === 'github' + ? 'https://api.github.com' + : (document.getElementById('host-url') as HTMLInputElement).value.trim(); + + if (!repoPath || !apiToken) { + setState({ error: 'Repository and API token are required' }); + return; + } + if (wizardConfig.hostType === 'gitea' && !hostUrl) { + setState({ error: 'Host URL is required for Gitea' }); + return; + } + + wizardConfig = { ...wizardConfig, hostUrl, repoPath, apiToken }; + wizardStep = 1; + setState({ error: null }); + }); +} + +// --- Step 1: Reference image upload --- + +function renderStep1(): string { + return ` +
+

reference image

+

+ Upload the JPEG that contains your embedded secret. + This is the second factor for vault decryption. +

+
+ + ${wizardImageBase64 + ? '

image loaded

' + : '

click to select JPEG

'} +
+
+ + +
+
+ `; +} + +function attachStep1Listeners(): void { + const fileDrop = document.getElementById('file-drop')!; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + + fileDrop.addEventListener('click', () => fileInput.click()); + + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Remove the data:image/jpeg;base64, prefix. + const base64 = result.split(',')[1] ?? result; + wizardImageBase64 = base64; + setState({ error: null }); // Re-render to show "image loaded". + }; + reader.readAsDataURL(file); + }); + + document.getElementById('back-btn')?.addEventListener('click', () => { + wizardStep = 0; + setState({ error: null }); + }); + + document.getElementById('next-btn')?.addEventListener('click', () => { + if (!wizardImageBase64) return; + wizardStep = 2; + setState({ error: null }); + }); +} + +// --- Step 2: Test unlock --- + +function renderStep2(state: ReturnType): string { + return ` +
+

test unlock

+

+ Enter your passphrase to verify the configuration works. +

+
+ +
+ ${state.loading ? '
' : ''} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+ + +
+
+ `; +} + +function attachStep2Listeners(): void { + document.getElementById('back-btn')?.addEventListener('click', () => { + wizardStep = 1; + setState({ error: null }); + }); + + const saveBtn = document.getElementById('save-btn'); + const input = document.getElementById('test-passphrase') as HTMLInputElement; + + const doSave = async () => { + const passphrase = input?.value; + if (!passphrase) { + setState({ error: 'Passphrase is required' }); + return; + } + + setState({ loading: true, error: null }); + + // Save config first. + const saveResp = await sendMessage({ + type: 'save_setup', + config: wizardConfig as VaultConfig, + imageBase64: wizardImageBase64!, + }); + + if (!saveResp.ok) { + setState({ loading: false, error: saveResp.error }); + return; + } + + // Try to unlock. + const unlockResp = await sendMessage({ type: 'unlock', passphrase }); + if (!unlockResp.ok) { + setState({ loading: false, error: unlockResp.error }); + return; + } + + // Success — go to entry list. + const listResp = await sendMessage({ type: 'list_entries' }); + if (listResp.ok) { + const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; + // Reset wizard state for next time. + wizardStep = 0; + wizardConfig = { hostType: 'gitea' }; + wizardImageBase64 = null; + navigate('list', { entries: data.entries }); + } else { + setState({ loading: false, error: listResp.error }); + } + }; + + saveBtn?.addEventListener('click', doSave); + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doSave(); + }); + + input?.focus(); +} diff --git a/extension/src/popup/components/unlock.ts b/extension/src/popup/components/unlock.ts new file mode 100644 index 0000000..bff615a --- /dev/null +++ b/extension/src/popup/components/unlock.ts @@ -0,0 +1,56 @@ +/// Unlock view — passphrase input with ENTER to submit. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { ManifestEntry } from '../../shared/types'; + +export function renderUnlock(app: HTMLElement): void { + const state = getState(); + + app.innerHTML = ` +
+
idfoto
+

two-factor vault

+
+ +
+ ${state.loading ? '
' : ''} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+ +
+
+ `; + + const input = document.getElementById('passphrase-input') as HTMLInputElement; + if (input && !state.loading) { + input.focus(); + input.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + const passphrase = input.value; + if (!passphrase) return; + setState({ loading: true, error: null }); + const resp = await sendMessage({ type: 'unlock', passphrase }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_entries' }); + if (listResp.ok) { + const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; + navigate('list', { entries: data.entries }); + } else { + setState({ loading: false, error: listResp.error }); + } + } else { + setState({ loading: false, error: resp.error }); + } + } + }); + } + + const settingsBtn = document.getElementById('settings-btn'); + settingsBtn?.addEventListener('click', () => navigate('setup')); +} diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts new file mode 100644 index 0000000..5a88aa8 --- /dev/null +++ b/extension/src/popup/popup.ts @@ -0,0 +1,133 @@ +/// Popup entry point — state machine with view routing. +/// +/// Views: setup | locked | list | detail | add | edit +/// Navigation works by updating `currentState` and calling `render()`. + +import type { Request, Response } from '../shared/messages'; +import type { ManifestEntry, Entry } from '../shared/types'; +import { renderUnlock } from './components/unlock'; +import { renderEntryList } from './components/entry-list'; +import { renderEntryDetail } from './components/entry-detail'; +import { renderEntryForm } from './components/entry-form'; +import { renderSetupWizard } from './components/setup-wizard'; + +// --- Escape HTML to prevent XSS --- +export function escapeHtml(str: string): string { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// --- State --- + +export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit'; + +export interface PopupState { + view: View; + entries: Array<[string, ManifestEntry]>; + selectedId: string | null; + selectedEntry: Entry | null; + selectedIndex: number; + searchQuery: string; + activeGroup: string | null; + error: string | null; + loading: boolean; +} + +let currentState: PopupState = { + view: 'locked', + entries: [], + selectedId: null, + selectedEntry: null, + selectedIndex: 0, + searchQuery: '', + activeGroup: null, + error: null, + loading: false, +}; + +export function getState(): PopupState { + return currentState; +} + +export function setState(partial: Partial): void { + currentState = { ...currentState, ...partial }; + render(); +} + +// --- Messaging --- + +export function sendMessage(request: Request): Promise { + return new Promise((resolve) => { + chrome.runtime.sendMessage(request, (response: Response) => { + resolve(response); + }); + }); +} + +// --- Navigation --- + +export function navigate(view: View, extras?: Partial): void { + setState({ view, error: null, loading: false, ...extras }); +} + +// --- Render --- + +function render(): void { + const app = document.getElementById('app'); + if (!app) return; + + switch (currentState.view) { + case 'setup': + renderSetupWizard(app); + break; + case 'locked': + renderUnlock(app); + break; + case 'list': + renderEntryList(app); + break; + case 'detail': + renderEntryDetail(app); + break; + case 'add': + renderEntryForm(app, 'add'); + break; + case 'edit': + renderEntryForm(app, 'edit'); + break; + } +} + +// --- Init --- + +async function init(): Promise { + // Check if extension is configured. + const setupResp = await sendMessage({ type: 'get_setup_state' }); + if (setupResp.ok) { + const data = setupResp.data as { isConfigured: boolean }; + if (!data.isConfigured) { + navigate('setup'); + return; + } + } + + // Check if vault is unlocked. + const unlockResp = await sendMessage({ type: 'is_unlocked' }); + if (unlockResp.ok) { + const data = unlockResp.data as { unlocked: boolean }; + if (data.unlocked) { + // Load entries and go to list. + const listResp = await sendMessage({ type: 'list_entries' }); + if (listResp.ok) { + const listData = listResp.data as { entries: Array<[string, ManifestEntry]> }; + navigate('list', { entries: listData.entries }); + return; + } + } + } + + navigate('locked'); +} + +document.addEventListener('DOMContentLoaded', init);