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>
256 lines
6.8 KiB
TypeScript
256 lines
6.8 KiB
TypeScript
/// 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 });
|
|
}
|
|
});
|
|
}
|