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:
adlee-was-taken
2026-04-12 09:42:23 -04:00
parent caf360c978
commit b4febbbe45
6 changed files with 1018 additions and 0 deletions

View 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 });
}
});
}

View 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();
}

View 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>&uarr;&darr;</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;
}
}

View 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();
}

View 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'));
}