feat: add popup state machine and all components
View router (setup/locked/list/detail/add/edit), unlock screen with passphrase input, entry list with search/group tabs/keyboard nav, entry detail with TOTP countdown and copy shortcuts, add/edit form with password generation, and 3-step setup wizard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
255
extension/src/popup/components/entry-detail.ts
Normal file
255
extension/src/popup/components/entry-detail.ts
Normal file
@@ -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<typeof setInterval> | null = null;
|
||||
|
||||
function stopTotpTimer(): void {
|
||||
if (totpInterval !== null) {
|
||||
clearInterval(totpInterval);
|
||||
totpInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<void> {
|
||||
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 = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-title">${escapeHtml(entry.name)}</span>
|
||||
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// URL
|
||||
if (entry.url) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">url</div>
|
||||
<div class="field-value">${escapeHtml(entry.url)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Username
|
||||
if (entry.username) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">username</div>
|
||||
<div class="field-value" id="username-val">${escapeHtml(entry.username)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Password (masked by default)
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">password</div>
|
||||
<div class="field-value" id="password-val" style="cursor:pointer;">
|
||||
<span id="password-display">********</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TOTP
|
||||
if (entry.totp_secret) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">totp</div>
|
||||
<div class="totp-code" id="totp-code">------</div>
|
||||
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Notes
|
||||
if (entry.notes) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">notes</div>
|
||||
<div class="field-value">${escapeHtml(entry.notes)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group
|
||||
if (entry.group) {
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="label">group</div>
|
||||
<div class="field-value">${escapeHtml(entry.group)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
html += `
|
||||
<div class="field">
|
||||
<div class="muted">updated ${escapeHtml(entry.updated_at)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Key hints
|
||||
html += `
|
||||
<div class="keyhints">
|
||||
<span><kbd>c</kbd> copy user</span>
|
||||
<span><kbd>p</kbd> copy pass</span>
|
||||
${entry.totp_secret ? '<span><kbd>t</kbd> copy totp</span>' : ''}
|
||||
<span><kbd>f</kbd> autofill</span>
|
||||
<span><kbd>e</kbd> edit</span>
|
||||
<span><kbd>d</kbd> delete</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<void> {
|
||||
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 = `
|
||||
<div class="confirm-box">
|
||||
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
|
||||
<button class="btn" id="cancel-delete">cancel</button>
|
||||
<button class="btn btn-danger" id="confirm-delete">delete</button>
|
||||
</div>
|
||||
`;
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
142
extension/src/popup/components/entry-form.ts
Normal file
142
extension/src/popup/components/entry-form.ts
Normal file
@@ -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 = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-name">name *</label>
|
||||
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
|
||||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
173
extension/src/popup/components/entry-list.ts
Normal file
173
extension/src/popup/components/entry-list.ts
Normal file
@@ -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<string>();
|
||||
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
|
||||
? `<div class="group-tabs">
|
||||
<button class="group-tab ${!state.activeGroup ? 'active' : ''}" data-group="">all</button>
|
||||
${groups.map(g =>
|
||||
`<button class="group-tab ${state.activeGroup === g ? 'active' : ''}" data-group="${escapeHtml(g)}">${escapeHtml(g)}</button>`
|
||||
).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const entriesHtml = filtered.length > 0
|
||||
? filtered.map(([id, e], i) => `
|
||||
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
|
||||
<span class="entry-name">${escapeHtml(e.name)}</span>
|
||||
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="empty">no entries</div>';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
|
||||
</div>
|
||||
${groupTabsHtml}
|
||||
<div class="entry-list" id="entry-list">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
<div class="keyhints">
|
||||
<span><kbd>/</kbd> search</span>
|
||||
<span><kbd>+</kbd> add</span>
|
||||
<span><kbd>↑↓</kbd> nav</span>
|
||||
<span><kbd>Enter</kbd> open</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// --- 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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
259
extension/src/popup/components/setup-wizard.ts
Normal file
259
extension/src/popup/components/setup-wizard.ts
Normal file
@@ -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<VaultConfig> = {
|
||||
hostType: 'gitea',
|
||||
};
|
||||
let wizardImageBase64: string | null = null;
|
||||
|
||||
export function renderSetupWizard(app: HTMLElement): void {
|
||||
const state = getState();
|
||||
|
||||
// Progress bar.
|
||||
const progressHtml = `
|
||||
<div class="progress-bar">
|
||||
<div class="step ${wizardStep > 0 ? 'done' : wizardStep === 0 ? 'current' : ''}"></div>
|
||||
<div class="step ${wizardStep > 1 ? 'done' : wizardStep === 1 ? 'current' : ''}"></div>
|
||||
<div class="step ${wizardStep > 2 ? 'done' : wizardStep === 2 ? 'current' : ''}"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let stepHtml = '';
|
||||
|
||||
switch (wizardStep) {
|
||||
case 0:
|
||||
stepHtml = renderStep0();
|
||||
break;
|
||||
case 1:
|
||||
stepHtml = renderStep1();
|
||||
break;
|
||||
case 2:
|
||||
stepHtml = renderStep2(state);
|
||||
break;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div class="brand" style="margin-bottom:4px;">idfoto setup</div>
|
||||
${progressHtml}
|
||||
${stepHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 `
|
||||
<div class="wizard-step">
|
||||
<h3>git host</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">host type</label>
|
||||
<div class="toggle-group">
|
||||
<button class="${wizardConfig.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
|
||||
<button class="${wizardConfig.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="host-url-group" ${wizardConfig.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||
<label class="label" for="host-url">host url</label>
|
||||
<input id="host-url" type="text" value="${escapeHtml(wizardConfig.hostUrl ?? '')}" placeholder="https://git.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="repo-path">repository</label>
|
||||
<input id="repo-path" type="text" value="${escapeHtml(wizardConfig.repoPath ?? '')}" placeholder="user/vault">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="api-token">api token</label>
|
||||
<input id="api-token" type="password" value="${escapeHtml(wizardConfig.apiToken ?? '')}" placeholder="token with repo read/write access">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" id="next-btn">next</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="wizard-step">
|
||||
<h3>reference image</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
Upload the JPEG that contains your embedded secret.
|
||||
This is the second factor for vault decryption.
|
||||
</p>
|
||||
<div class="file-drop ${wizardImageBase64 ? 'has-file' : ''}" id="file-drop">
|
||||
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
|
||||
${wizardImageBase64
|
||||
? '<p class="secondary">image loaded</p>'
|
||||
: '<p class="secondary">click to select JPEG</p>'}
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:16px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="next-btn" ${!wizardImageBase64 ? 'disabled' : ''}>next</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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<typeof getState>): string {
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>test unlock</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">
|
||||
Enter your passphrase to verify the configuration works.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input id="test-passphrase" type="password" placeholder="passphrase" ${state.loading ? 'disabled' : ''}>
|
||||
</div>
|
||||
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="save-btn" ${state.loading ? 'disabled' : ''}>save & unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
56
extension/src/popup/components/unlock.ts
Normal file
56
extension/src/popup/components/unlock.ts
Normal file
@@ -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 = `
|
||||
<div class="pad" style="text-align:center; padding-top:40px;">
|
||||
<div class="brand">idfoto</div>
|
||||
<p class="muted" style="margin:8px 0 24px;">two-factor vault</p>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="password"
|
||||
id="passphrase-input"
|
||||
placeholder="passphrase"
|
||||
autocomplete="off"
|
||||
${state.loading ? 'disabled' : ''}
|
||||
>
|
||||
</div>
|
||||
${state.loading ? '<div style="margin:12px 0;"><span class="spinner"></span></div>' : ''}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div style="margin-top:24px;">
|
||||
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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'));
|
||||
}
|
||||
133
extension/src/popup/popup.ts
Normal file
133
extension/src/popup/popup.ts
Normal file
@@ -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<PopupState>): void {
|
||||
currentState = { ...currentState, ...partial };
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Messaging ---
|
||||
|
||||
export function sendMessage(request: Request): Promise<Response> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(request, (response: Response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export function navigate(view: View, extras?: Partial<PopupState>): 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<void> {
|
||||
// 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);
|
||||
Reference in New Issue
Block a user