import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { isPasskeyOnly } from "../../shared/types"; import { detectBrowser } from "../../shared/browser"; import { getDisplayDomain } from "../../shared/domain"; import { faviconUrl } from "../../shared/favicon"; import { generateTOTP, parseOtpauthURI } from "../../crypto/totp"; import { sendMessage, triggerPipPulse, triggerRing } from "../App"; import { popupCopy } from "../copy"; import { GeneratorDrawer } from "../components/GeneratorDrawer"; import { NoteEditorInline, Toolbar } from "./NoteEditor"; type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null; interface EntryProps { entry: VaultEntry | null; /** * Current active tab's full URL (https only). Empty string when unavailable * — Capacitor, an extension page, or a chrome:///about: URL. The URL * editor uses this to render an "add current site" shortcut. */ currentTabUrl?: string; onDelete: () => void; /** Called after any inline save so App can reload entries */ onFieldSaved: () => void | Promise; onEscape?: () => void; } export function EntryView(props: EntryProps) { const [editingField, setEditingField] = createSignal(null); const [draft, setDraft] = createSignal(""); const [saving, setSaving] = createSignal(false); const [showPassword, setShowPassword] = createSignal(false); const [showGenerator, setShowGenerator] = createSignal(false); const [copied, setCopied] = createSignal(null); // Local draft for the additional URLs list, edited only while the URL field // is in inline-edit mode. Saved alongside `url` when the user confirms. const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal([]); const [editingTags, setEditingTags] = createSignal(false); const [tagsDraft, setTagsDraft] = createSignal([]); const [tagInput, setTagInput] = createSignal(""); const [showCardNumber, setShowCardNumber] = createSignal(false); const [showCardCvc, setShowCardCvc] = createSignal(false); const [totpTick, setTotpTick] = createSignal(0); const [confirmDelete, setConfirmDelete] = createSignal(false); const [totpHot, setTotpHot] = createSignal(false); const [faviconSrc, setFaviconSrc] = createSignal(null); const [noteEditorEl, setNoteEditorEl] = createSignal(null); // Auto-open advanced when entry already has passkey or canary configured const [showAdvanced, setShowAdvanced] = createSignal(false); let lastSavedNote: string | null = null; function noteExec(cmd: string, val?: string) { document.execCommand(cmd, false, val); noteEditorEl()?.focus(); } async function saveNoteInline(html: string) { const entry = props.entry; if (!entry) return; const currentStored = entry.notes ?? ""; if (html === currentStored) return; if (lastSavedNote !== null && html === lastSavedNote) return; lastSavedNote = html; setSaving(true); try { await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { notes: html } }, }); triggerRing(); await props.onFieldSaved(); } catch { lastSavedNote = null; } finally { setSaving(false); } } const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000); onCleanup(() => window.clearInterval(interval)); const displayDomain = createMemo(() => props.entry?.url ? getDisplayDomain(props.entry.url) : "" ); createEffect(() => { const domain = displayDomain(); setFaviconSrc(domain ? faviconUrl(domain) : null); }); // Reset transient prompts (trash confirmation, inline edits, advanced panel) // when the selected entry changes so they don't carry over between entries. createEffect(() => { void props.entry?.id; setConfirmDelete(false); setEditingField(null); setEditingTags(false); setShowAdvanced(false); }); const totpState = createMemo(() => { void totpTick(); if (!props.entry?.totp) return null; return generateTOTP(props.entry.totp); }); let prevTotpCode: string | undefined; createEffect(() => { const state = totpState(); if (!state) { prevTotpCode = undefined; return; } if (prevTotpCode !== undefined && state.code !== prevTotpCode) { setTotpHot(true); window.setTimeout(() => setTotpHot(false), 3000); } prevTotpCode = state.code; }); const totpProgress = createMemo(() => { const state = totpState(); if (!state) return 0; return ((state.remaining - 1) / 29) * 100; }); createEffect(() => { const shell = document.getElementById("hd-shell"); if (!shell) return; const state = totpState(); if (!state) { delete shell.dataset.totpActive; shell.style.removeProperty("--peach-totp-progress"); return; } shell.dataset.totpActive = "true"; shell.style.setProperty( "--peach-totp-progress", `${Math.max(0, totpProgress())}%` ); onCleanup(() => { delete shell.dataset.totpActive; shell.style.removeProperty("--peach-totp-progress"); }); }); const titleSizeClass = createMemo(() => { const length = props.entry?.title.length ?? 0; if (length >= 28) return "peach-entry-name--xxl"; if (length >= 20) return "peach-entry-name--xl"; if (length >= 14) return "peach-entry-name--lg"; return ""; }); const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13); // ── Inline editing ──────────────────────────────────────────────── function startEdit(field: EditableField, value: string) { setEditingField(field); setDraft(value); setShowGenerator(false); setConfirmDelete(false); if (field === "url") { setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]); } } // Compare URLs by string after trim — vault.ts has its own canonicalisation // when saving, so the dedupe here is a UX-level "don't show the button if // we'd just add a duplicate" rather than a security check. function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean { const c = candidate.trim(); if (!c) return true; if (primary.trim() === c) return true; return others.some((u) => u.trim() === c); } function addCurrentSite(url: string) { if (!url) return; if (!draft().trim()) { setDraft(url); return; } if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return; setAdditionalUrlsDraft((arr) => [...arr, url]); } async function saveField() { const field = editingField(); const entry = props.entry; if (!field || !entry) return; // Don't trim passwords — spaces may be intentional let value = field === "password" ? draft() : draft().trim(); if (field === "totp" && value.startsWith("otpauth://")) { const parsed = parseOtpauthURI(value); value = parsed?.secret ?? value; } if (field === "password" && entry.type !== "note" && !value) { return; } setSaving(true); try { const patch: Partial = { [field]: value }; if (field === "url") { // Sanitization layer in vault.ts trims, dedupes, and drops empties. patch.additionalUrls = additionalUrlsDraft(); } await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch }, }); triggerRing(); setEditingField(null); setShowGenerator(false); await props.onFieldSaved(); } catch { // silent — leave editing open } finally { setSaving(false); } } function startEditTags() { setTagsDraft([...(props.entry?.tags ?? [])]); setTagInput(""); setEditingTags(true); } function addTag(value: string) { const n = value.toLowerCase().trim(); if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return; setTagsDraft([...tagsDraft(), n]); setTagInput(""); } async function saveTags() { if (tagInput()) addTag(tagInput()); const entry = props.entry; if (!entry) return; const next = tagsDraft().length > 0 ? tagsDraft() : undefined; setEditingTags(false); setSaving(true); try { await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { tags: next } }, }); triggerRing(); await props.onFieldSaved(); } catch { // silent } finally { setSaving(false); } } function cancelEdit() { setEditingField(null); setDraft(""); setShowGenerator(false); } function fieldValue(entry: VaultEntry, field: Exclude): string { switch (field) { case "title": return entry.title; case "url": return entry.url ?? ""; case "username": return entry.username ?? ""; case "email": return entry.email ?? ""; case "password": return entry.password ?? ""; case "notes": return entry.notes ?? ""; case "totp": return entry.totp ?? ""; } } function handleEditorFocusOut( field: Exclude, event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null } ) { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; const entry = props.entry; if (!entry) return; const currentValue = fieldValue(entry, field); const nextValue = field === "password" ? draft() : draft().trim(); if (nextValue === currentValue) { cancelEdit(); } } async function savePassword(password: string) { const entry = props.entry; if (!entry) return; setSaving(true); try { await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { password } }, }); triggerRing(); setShowGenerator(false); await props.onFieldSaved(); } catch { // silent } finally { setSaving(false); } } // ── Copy ───────────────────────────────────────────────────────── async function copyField(text: string, label: string, button?: HTMLButtonElement) { const ok = await popupCopy(text, label); if (ok) { const entry = props.entry; if (entry?.canary?.webhookUrl) { const trigger = label === "password" ? "password_copied" : label === "username" ? "username_copied" : undefined; if (trigger) { void sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger }, }); } } triggerRing(); triggerPipPulse(); setCopied(null); requestAnimationFrame(() => setCopied(label)); if (button) { button.classList.remove("is-copied", "is-retreat"); void button.offsetWidth; const drain = document.createElement("span"); drain.className = "peach-copy-drain"; const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar"; drain.appendChild(bar); button.appendChild(drain); window.setTimeout(() => { button.classList.remove("is-copied"); button.classList.add("is-retreat"); drain.remove(); window.setTimeout(() => button.classList.remove("is-retreat"), 160); }, 1800); } window.setTimeout(() => setCopied(null), 1800); } } // ── Keyboard shortcuts ──────────────────────────────────────────── createEffect(() => { if (!props.entry) return; const handler = (e: KeyboardEvent) => { const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable; if (inInput) return; switch (e.key) { case "c": e.preventDefault(); if (props.entry?.password) void copyField(props.entry.password, "password"); break; case "u": e.preventDefault(); if (props.entry?.username) void copyField(props.entry.username, "username"); break; case "Escape": e.preventDefault(); props.onEscape?.(); break; } }; window.addEventListener("keydown", handler); onCleanup(() => window.removeEventListener("keydown", handler)); }); // ── Input ref focus helper ──────────────────────────────────────── function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) { requestAnimationFrame(() => { el.focus(); el.select?.(); }); } function onEditKeyDown(e: KeyboardEvent) { if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); void saveField(); } if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } } return ( <>
}> {(entry) => ( <> {/* ── Breach strip ─────────────────────────────────── */} 0}>
found in a known data breach
{/* ── Title + trash ─────────────────────────────────── */}
startEdit("title", entry().title)} >

{(ch, i) => ( {ch === " " ? "\u00A0" : ch} )}

} >
handleEditorFocusOut("title", event)} > setDraft(e.currentTarget.value)} onKeyDown={onEditKeyDown} ref={autoFocus} disabled={saving()} />
{/* Subtle trash icon — hidden while editing title */} setConfirmDelete(true)} aria-label="Move to trash" title="Delete entry" > } >
trash?
{/* ── Domain / URL ─────────────────────────────────── */}
startEdit("url", entry().url ?? "")} > { (e.currentTarget as HTMLImageElement).style.display = "none"; }} /> {displayDomain() || (entry().url ? entry().url : "add a website url")} edit
} >
handleEditorFocusOut("url", event)} >
setDraft(e.currentTarget.value)} onKeyDown={onEditKeyDown} ref={autoFocus} disabled={saving()} />
0}> also matches {(value, i) => (
setAdditionalUrlsDraft((arr) => { const next = [...arr]; next[i()] = e.currentTarget.value; return next; })} disabled={saving()} />
)}
{/* ── Tags — read-only chips (editing state lives at bottom) */} 0}>
{(tag) => {tag}}
{/* ── Note toolbar + inline editor ─────────────────── */}
{ setNoteEditorEl(el); lastSavedNote = null; }} onBlur={saveNoteInline} autoFocus={false} />
{/* ── Card detail view ─────────────────────────────── */}
{/* Card subtitle: •••• last4 */}
{entry().network} {entry().cardNickname || `•••• ${entry().last4}`}
{/* Cardholder name */}
cardholder name
{entry().cardholderName || "—"}
{/* Card number */}
card number
{`•••• •••• •••• ${entry().last4 ?? "••••"}`} }> {entry().cardNumber ?? ""}
{/* Expiry */}
expiry
{entry().expMonth} / {entry().expYear}
{/* CVC */}
CVC
•••}> {entry().cvc}
{/* ── Login fields ─────────────────────────────────── */}
{/* Username — shown when set, or as the only identity field when neither is set */}
{entry().username?.includes("@") ? "email" : "username"} startEdit("username", entry().username ?? "")} > {entry().username || click to add username}
} >
handleEditorFocusOut("username", event)} > setDraft(e.currentTarget.value)} onKeyDown={onEditKeyDown} ref={autoFocus} disabled={saving()} autocomplete="off" />
{/* Email — shown when set, or when actively being added */}
email startEdit("email", entry().email ?? "")} > {entry().email}
} >
handleEditorFocusOut("email", event)} > setDraft(e.currentTarget.value)} onKeyDown={onEditKeyDown} ref={autoFocus} disabled={saving()} autocomplete="email" />
{/* Password */}
password 0 }} style="cursor:pointer;" title="Click to edit password" onClick={() => startEdit("password", entry().password ?? "")} > ••••••••••••••} > {entry().password ?? ""}
} > {/* ── Edit mode: type / paste / generate ── */}
handleEditorFocusOut("password", event)} >
setDraft(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void saveField(); } if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }} ref={autoFocus} disabled={saving()} autocomplete="new-password" />
{ setDraft(pw); setShowGenerator(false); }} />
{/* ── Notes (on login entries) ──────────────────────── */} startEdit("notes", "")} > + notes } >
notes startEdit("notes", entry().notes ?? "")} > {entry().notes}
} >
handleEditorFocusOut("notes", event)} >