import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import type { ProFeature, VaultEntry } from "../../shared/types"; import { isPasskeyOnly } from "../../shared/types"; import { detectBrowser } from "../../shared/browser"; import { detectHomograph, getDisplayDomain } from "../../shared/domain"; import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } 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"; import { IdentitySuggestList } from "./Add"; type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null; // Compact "time since" label for the password-history list. Coarse on purpose — // the user wants "roughly when", not a precise duration. function timeSince(when: number): string { const secs = Math.max(0, Math.floor((Date.now() - when) / 1000)); if (secs < 60) return "just now"; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo ago`; return `${Math.floor(months / 12)}y ago`; } interface EntryProps { entry: VaultEntry | null; /** * Current active tab's full URL (https only). Empty string when unavailable * — 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; entries?: VaultEntry[]; /** Whether the user holds an active Peach Pro license. Drives Pro gates on * canary entries (see CanarySection). */ isPro?: boolean; /** Invoked when a Pro-gated control is tapped on free tier. App owns the * upgrade navigation + highlight state. */ onProGate?: (feature: ProFeature) => void; } // Inline 16×16 favicon for the domain row — shows coloured initials until a // real favicon is confirmed (same globe-detection logic as the vault list). function DomainFavicon(props: { domain: string; title: string }) { const src = props.domain ? faviconUrl(props.domain) : null; const [isReal, setIsReal] = createSignal(false); const label = () => titleInitials(props.title || props.domain); const bg = () => titleBackground(props.title || props.domain); const chipStyle = () => `display:inline-flex;align-items:center;justify-content:center;` + `width:16px;height:16px;border-radius:3px;background:${bg()};` + `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` + `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`; return ( {src && ( {}} onLoad={async (e) => { const img = e.currentTarget as HTMLImageElement; if (img.naturalWidth <= 16) return; const sig = faviconImgSig(img); if (sig !== null) { const gSig = await globeFaviconSig(); if (gSig !== null && sig === gSig) return; } setIsReal(true); }} /> )} } > ); } 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 [showHistory, setShowHistory] = createSignal(false); const [revealedHistory, setRevealedHistory] = createSignal>(new Set()); 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 [noteEditorEl, setNoteEditorEl] = createSignal(null); // Auto-open advanced when entry already has passkey or canary configured const [showAdvanced, setShowAdvanced] = createSignal(false); const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false); let usernameInlineInput: HTMLInputElement | undefined; let emailInlineInput: HTMLInputElement | undefined; let usernameIdentityEl: HTMLDivElement | undefined; let emailIdentityEl: HTMLDivElement | undefined; let lastSavedNote: string | null = null; let totpClickTimer: ReturnType | null = null; onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); }); 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 allIdentities = createMemo(() => { const emailMap = new Map(); const usernameMap = new Map(); for (const e of props.entries ?? []) { const uname = e.username?.trim() ?? ""; const em = e.email?.trim() ?? ""; if (uname) { if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1); else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1); } if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1); } const result: Array<{ value: string; count: number; type: "email" | "username" }> = []; const seen = new Set(); for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) { if (seen.has(v)) continue; seen.add(v); const ec = emailMap.get(v) ?? 0; const uc = usernameMap.get(v) ?? 0; result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" }); } return result.sort((a, b) => b.count - a.count); }); const identitySuggestions = createMemo(() => { const field = editingField(); if (field !== "username" && field !== "email") return []; return allIdentities() // Inline editing starts with the saved value selected. Keep the full // identity list visible so editing an existing value is a picker, not a // one-row exact-match result. AddView still filters while typing. .filter(s => s.type === field) .slice(0, 8); }); const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000); onCleanup(() => window.clearInterval(interval)); const displayDomain = createMemo(() => props.entry?.url ? getDisplayDomain(props.entry.url) : "" ); // Reset transient prompts (trash confirmation, inline edits, advanced panel) // when the selected entry changes so they don't carry over between entries. // Track the last id so a same-entry refresh (e.g. after saving a field) // doesn't blow away in-flight edit state — that race used to cancel // startEdit calls that ran right after onFieldSaved. let lastEntryId: string | null = null; createEffect(() => { const id = props.entry?.id ?? null; if (id === lastEntryId) return; lastEntryId = 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, props.entry.totpAlgorithm, props.entry.totpDigits, props.entry.totpPeriod, ); }); 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); setIdentityHelperSuppressed(false); setShowGenerator(false); setShowHistory(false); setRevealedHistory(new Set()); 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); setShowHistory(false); setRevealedHistory(new Set()); 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); setShowHistory(false); setRevealedHistory(new Set()); } // Switch from one identity slot to the other. // // Migration source is the SAVED entry value, never the live draft — using // the draft caused data loss when the user typed or cleared the input // before clicking +add (the migration would skip and a later Save would // overwrite the original email-shape value with whatever was in the input). // // Drafts only get committed when they are non-empty AND actually changed, // so an empty input never accidentally clears a saved field. async function addOtherIdentity(target: "username" | "email") { const entry = props.entry; if (!entry) return; const current = editingField(); const draftValue = draft().trim(); const savedUsername = entry.username ?? ""; const savedEmail = entry.email ?? ""; if (current === "username" && target === "username") { // Legacy fix-up: saved username is email-shaped and no separate email // exists yet. Move it to entry.email and free the username slot. if (savedUsername.includes("@") && !entry.email) { setSaving(true); try { await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { email: savedUsername, username: "" } }, }); await props.onFieldSaved(); } finally { setSaving(false); } } startEdit("username", ""); return; } if (current === "username" && target === "email") { if (draftValue && draftValue !== savedUsername) { setSaving(true); try { await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { username: draftValue } }, }); await props.onFieldSaved(); } finally { setSaving(false); } } startEdit("email", ""); return; } if (current === "email" && target === "username") { if (draftValue && draftValue !== savedEmail) { setSaving(true); try { await sendMessage({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { email: draftValue } }, }); await props.onFieldSaved(); } finally { setSaving(false); } } startEdit("username", ""); return; } } 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; // addOtherIdentity / startEdit may have already switched editingField before // focusout fires (SolidJS removes the old editor node synchronously, which // triggers blur with relatedTarget=null). Don't cancel the new edit. if (editingField() !== field) return; const entry = props.entry; if (!entry) return; const currentValue = fieldValue(entry, field); const nextValue = field === "password" ? draft() : draft().trim(); if (nextValue === currentValue) { cancelEdit(); } } // ── Copy ───────────────────────────────────────────────────────── async function copyField(text: string, label: string, button?: HTMLButtonElement) { const entry = props.entry; const ok = await popupCopy( text, label, entry?.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined, ); if (ok) { 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); } } // The entry view owns its direct keyboard fallback; modified copy keys must // remain native clipboard shortcuts and never copy a password. 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 || e.ctrlKey || e.metaKey || e.altKey) 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(); } } function identityKeyDown(e: KeyboardEvent, field: "username" | "email") { const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl; if (e.key === "ArrowDown" && listEl) { e.preventDefault(); listEl.querySelector(".peach-identity-suggestion")?.focus(); return; } onEditKeyDown(e); } 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 ?? "")} > {displayDomain() || (entry().url ? entry().url : "add a website url")} edit
} >
handleEditorFocusOut("url", event)} >
setDraft(e.currentTarget.value)} onKeyDown={onEditKeyDown} ref={autoFocus} disabled={saving()} />
detectHomograph(u)) } > {/* UI-8 / LV-2: non-blocking homograph warning. */}

⚠ This URL contains non-ASCII or punycode characters. Verify it's the site you intend.

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 — primary identity slot. Auto-labels as "email" when the stored value is email-shaped and no separate email field exists (legacy data path). */}
{entry().username?.includes("@") && !entry().email ? "email" : "username"} startEdit("username", entry().username ?? "")} > {entry().username || click to add username}
} >
handleEditorFocusOut("username", event)} > setDraft(e.currentTarget.value)} onKeyDown={(e) => identityKeyDown(e, "username")} ref={(el) => { usernameInlineInput = el; autoFocus(el); }} disabled={saving()} autocomplete="off" /> 0 && editingField() === "username"}> (usernameIdentityEl = el)} inputRef={() => usernameInlineInput} suggestions={identitySuggestions()} onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }} onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }} /> {/* +add the OTHER identity. If the slot is acting as email (value has @, no separate email), offer "+ add username" — clicking migrates the email into entry.email. Otherwise offer "+ add email". */}
{/* Email — secondary identity slot. Visible whenever a separate email field is set, or while it is being edited. */}
email startEdit("email", entry().email ?? "")} > {entry().email}
} >
handleEditorFocusOut("email", event)} > setDraft(e.currentTarget.value)} onKeyDown={(e) => identityKeyDown(e, "email")} ref={(el) => { emailInlineInput = el; autoFocus(el); }} disabled={saving()} autocomplete="email" type="email" /> 0 && editingField() === "email"}> (emailIdentityEl = el)} inputRef={() => emailInlineInput} suggestions={identitySuggestions()} onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }} onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }} />
{/* 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); }} />
No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.
{(item, i) => (
••••••••••••} > {item.password} {timeSince(item.changedAt)}
)}
{/* ── Notes (on login entries) ──────────────────────── */} startEdit("notes", "")} > + notes } >
notes startEdit("notes", entry().notes ?? "")} > {entry().notes}
} >
handleEditorFocusOut("notes", event)} >