1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";1213type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1415interface EntryProps {16 entry: VaultEntry | null;17 1819202122 currentTabUrl?: string;23 onDelete: () => void;24 25 onFieldSaved: () => void | Promise<void>;26 onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30 const [editingField, setEditingField] = createSignal<EditableField>(null);31 const [draft, setDraft] = createSignal("");32 const [saving, setSaving] = createSignal(false);33 const [showPassword, setShowPassword] = createSignal(false);34 const [showGenerator, setShowGenerator] = createSignal(false);35 const [copied, setCopied] = createSignal<string | null>(null);36 37 38 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39 const [editingTags, setEditingTags] = createSignal(false);40 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41 const [tagInput, setTagInput] = createSignal("");42 const [showCardNumber, setShowCardNumber] = createSignal(false);43 const [showCardCvc, setShowCardCvc] = createSignal(false);44 const [totpTick, setTotpTick] = createSignal(0);45 const [confirmDelete, setConfirmDelete] = createSignal(false);46 const [totpHot, setTotpHot] = createSignal(false);47 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49 let lastSavedNote: string | null = null;5051 function noteExec(cmd: string, val?: string) {52 document.execCommand(cmd, false, val);53 noteEditorEl()?.focus();54 }5556 async function saveNoteInline(html: string) {57 const entry = props.entry;58 if (!entry) return;59 const currentStored = entry.notes ?? "";60 if (html === currentStored) return;61 if (lastSavedNote !== null && html === lastSavedNote) return;62 lastSavedNote = html;63 setSaving(true);64 try {65 await sendMessage({66 type: "UPDATE_ENTRY",67 payload: { id: entry.id, patch: { notes: html } },68 });69 triggerRing();70 await props.onFieldSaved();71 } catch {72 lastSavedNote = null;73 } finally {74 setSaving(false);75 }76 }7778 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);79 onCleanup(() => window.clearInterval(interval));8081 const displayDomain = createMemo(() =>82 props.entry?.url ? getDisplayDomain(props.entry.url) : ""83 );8485 createEffect(() => {86 const domain = displayDomain();87 setFaviconSrc(domain ? faviconUrl(domain) : null);88 });8990 91 92 createEffect(() => {93 void props.entry?.id;94 setConfirmDelete(false);95 setEditingField(null);96 setEditingTags(false);97 });9899 const totpState = createMemo(() => {100 void totpTick();101 if (!props.entry?.totp) return null;102 return generateTOTP(props.entry.totp);103 });104105 let prevTotpCode: string | undefined;106 createEffect(() => {107 const state = totpState();108 if (!state) { prevTotpCode = undefined; return; }109 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {110 setTotpHot(true);111 window.setTimeout(() => setTotpHot(false), 3000);112 }113 prevTotpCode = state.code;114 });115116 const totpProgress = createMemo(() => {117 const state = totpState();118 if (!state) return 0;119 return ((state.remaining - 1) / 29) * 100;120 });121122 createEffect(() => {123 const shell = document.getElementById("hd-shell");124 if (!shell) return;125126 const state = totpState();127 if (!state) {128 delete shell.dataset.totpActive;129 shell.style.removeProperty("--peach-totp-progress");130 return;131 }132133 shell.dataset.totpActive = "true";134 shell.style.setProperty(135 "--peach-totp-progress",136 `${Math.max(0, totpProgress())}%`137 );138139 onCleanup(() => {140 delete shell.dataset.totpActive;141 shell.style.removeProperty("--peach-totp-progress");142 });143 });144145 const titleSizeClass = createMemo(() => {146 const length = props.entry?.title.length ?? 0;147 if (length >= 28) return "peach-entry-name--xxl";148 if (length >= 20) return "peach-entry-name--xl";149 if (length >= 14) return "peach-entry-name--lg";150 return "";151 });152 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);153154 155156 function startEdit(field: EditableField, value: string) {157 setEditingField(field);158 setDraft(value);159 setShowGenerator(false);160 setConfirmDelete(false);161 if (field === "url") {162 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);163 }164 }165166 167 168 169 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {170 const c = candidate.trim();171 if (!c) return true;172 if (primary.trim() === c) return true;173 return others.some((u) => u.trim() === c);174 }175176 function addCurrentSite(url: string) {177 if (!url) return;178 if (!draft().trim()) {179 setDraft(url);180 return;181 }182 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;183 setAdditionalUrlsDraft((arr) => [...arr, url]);184 }185186 async function saveField() {187 const field = editingField();188 const entry = props.entry;189 if (!field || !entry) return;190 191 let value = field === "password" ? draft() : draft().trim();192 if (field === "totp" && value.startsWith("otpauth://")) {193 const parsed = parseOtpauthURI(value);194 value = parsed?.secret ?? value;195 }196 if (field === "password" && entry.type !== "note" && !value) {197 return;198 }199 setSaving(true);200 try {201 const patch: Partial<VaultEntry> = { [field]: value };202 if (field === "url") {203 204 patch.additionalUrls = additionalUrlsDraft();205 }206 await sendMessage({207 type: "UPDATE_ENTRY",208 payload: { id: entry.id, patch },209 });210 triggerRing();211 setEditingField(null);212 setShowGenerator(false);213 await props.onFieldSaved();214 } catch {215 216 } finally {217 setSaving(false);218 }219 }220221 function startEditTags() {222 setTagsDraft([...(props.entry?.tags ?? [])]);223 setTagInput("");224 setEditingTags(true);225 }226227 function addTag(value: string) {228 const n = value.toLowerCase().trim();229 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;230 setTagsDraft([...tagsDraft(), n]);231 setTagInput("");232 }233234 async function saveTags() {235 if (tagInput()) addTag(tagInput());236 const entry = props.entry;237 if (!entry) return;238 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;239 setEditingTags(false);240 setSaving(true);241 try {242 await sendMessage({243 type: "UPDATE_ENTRY",244 payload: { id: entry.id, patch: { tags: next } },245 });246 triggerRing();247 await props.onFieldSaved();248 } catch {249 250 } finally {251 setSaving(false);252 }253 }254255 function cancelEdit() {256 setEditingField(null);257 setDraft("");258 setShowGenerator(false);259 }260261 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {262 switch (field) {263 case "title":264 return entry.title;265 case "url":266 return entry.url ?? "";267 case "username":268 return entry.username ?? "";269 case "password":270 return entry.password ?? "";271 case "notes":272 return entry.notes ?? "";273 case "totp":274 return entry.totp ?? "";275 }276 }277278 function handleEditorFocusOut(279 field: Exclude<EditableField, null>,280 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }281 ) {282 const nextTarget = event.relatedTarget;283 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;284285 const entry = props.entry;286 if (!entry) return;287288 const currentValue = fieldValue(entry, field);289 const nextValue = field === "password" ? draft() : draft().trim();290 if (nextValue === currentValue) {291 cancelEdit();292 }293 }294295 async function savePassword(password: string) {296 const entry = props.entry;297 if (!entry) return;298 setSaving(true);299 try {300 await sendMessage({301 type: "UPDATE_ENTRY",302 payload: { id: entry.id, patch: { password } },303 });304 triggerRing();305 setShowGenerator(false);306 await props.onFieldSaved();307 } catch {308 309 } finally {310 setSaving(false);311 }312 }313314 315316 async function copyField(text: string, label: string, button?: HTMLButtonElement) {317 const ok = await popupCopy(text, label);318 if (ok) {319 const entry = props.entry;320 if (entry?.canary?.webhookUrl) {321 const trigger =322 label === "password"323 ? "password_copied"324 : label === "username"325 ? "username_copied"326 : undefined;327 if (trigger) {328 void sendMessage({329 type: "FIRE_CANARY_PING",330 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },331 });332 }333 }334 triggerRing();335 triggerPipPulse();336 setCopied(null);337 requestAnimationFrame(() => setCopied(label));338 if (button) {339 button.classList.remove("is-copied", "is-retreat");340 void button.offsetWidth;341 const drain = document.createElement("span");342 drain.className = "peach-copy-drain";343 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";344 drain.appendChild(bar);345 button.appendChild(drain);346 window.setTimeout(() => {347 button.classList.remove("is-copied");348 button.classList.add("is-retreat");349 drain.remove();350 window.setTimeout(() => button.classList.remove("is-retreat"), 160);351 }, 1800);352 }353 window.setTimeout(() => setCopied(null), 1800);354 }355 }356357 358359 createEffect(() => {360 if (!props.entry) return;361 const handler = (e: KeyboardEvent) => {362 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;363 if (inInput) return;364 switch (e.key) {365 case "c": e.preventDefault();366 if (props.entry?.password) void copyField(props.entry.password, "password"); break;367 case "u": e.preventDefault();368 if (props.entry?.username) void copyField(props.entry.username, "username"); break;369 case "Escape": e.preventDefault(); props.onEscape?.(); break;370 }371 };372 window.addEventListener("keydown", handler);373 onCleanup(() => window.removeEventListener("keydown", handler));374 });375376 377378 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {379 requestAnimationFrame(() => { el.focus(); el.select?.(); });380 }381382 function onEditKeyDown(e: KeyboardEvent) {383 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {384 e.preventDefault();385 void saveField();386 }387 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }388 }389390 return (391 <>392 <div393 class="peach-ghost"394 aria-hidden="true"395 style="position:absolute;top:30px;left:12px;right:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:150px;font-style:italic;font-weight:400;color:var(--t0);opacity:0.03;pointer-events:none;line-height:1;overflow:hidden;white-space:nowrap;z-index:0;"396 >397 {props.entry?.title || "empty"}398 </div>399400 <div class="peach-stage" style="position:relative;">401 <Show when={props.entry} fallback={<EmptyVaultState />}>402 {(entry) => (403 <>404 {}405 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>406 <div class="peach-breach-badge">407 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">408 <path d="M6 1L11 10H1L6 1z" />409 <line x1="6" y1="5" x2="6" y2="7.5" />410 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />411 </svg>412 <span style="flex:1">found in a known data breach</span>413 <button414 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"415 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}416 >417 Change password418 </button>419 </div>420 </Show>421422 {}423 <div class="peach-entry-header">424 <div class="peach-entry-header-name">425 <Show426 when={editingField() === "title"}427 fallback={428 <button429 class="peach-entry-name-button"430 title="Click to edit title"431 onClick={() => startEdit("title", entry().title)}432 >433 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>434 <span class="peach-entry-name-text">435 <Show436 when={useAnimatedTitle()}437 fallback={entry().title}438 >439 <For each={entry().title.split("")}>440 {(ch, i) => (441 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>442 {ch === " " ? "\u00A0" : ch}443 </span>444 )}445 </For>446 </Show>447 </span>448 </h1>449 </button>450 }451 >452 <div453 class="peach-inline-title-wrap"454 onFocusOut={(event) => handleEditorFocusOut("title", event)}455 >456 <input457 class="peach-inline-title-input"458 value={draft()}459 onInput={(e) => setDraft(e.currentTarget.value)}460 onKeyDown={onEditKeyDown}461 ref={autoFocus}462 disabled={saving()}463 />464 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />465 </div>466 </Show>467 </div>468469 {}470 <Show when={editingField() !== "title"}>471 <Show472 when={confirmDelete()}473 fallback={474 <button475 class="peach-entry-trash-btn"476 onClick={() => setConfirmDelete(true)}477 aria-label="Move to trash"478 title="Delete entry"479 >480 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">481 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />482 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />483 </svg>484 </button>485 }486 >487 <div class="peach-entry-trash-confirm">488 <span class="peach-entry-trash-confirm-label">trash?</span>489 <button490 class="peach-btn peach-btn--sm peach-btn--danger"491 style="font-size:10px;padding:2px 8px;min-height:22px;"492 onClick={() => { setConfirmDelete(false); props.onDelete(); }}493 >494 yes495 </button>496 <button497 class="peach-btn peach-btn--ghost peach-btn--sm"498 style="font-size:10px;padding:2px 8px;min-height:22px;"499 onClick={() => setConfirmDelete(false)}500 >501 no502 </button>503 </div>504 </Show>505 </Show>506 </div>507508 {}509 <Show when={entry().type !== "note" && entry().type !== "card"}>510 <div class="peach-domain" style="position:relative;z-index:1;">511 <Show512 when={editingField() === "url"}513 fallback={514 <div515 class="peach-domain-row peach-inline-clickable"516 title="Click to edit URL"517 onClick={() => startEdit("url", entry().url ?? "")}518 >519 <Show when={faviconSrc()}>520 <img521 src={faviconSrc()!}522 width="16"523 height="16"524 alt=""525 style="border-radius:3px;margin-right:5px;flex-shrink:0;"526 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}527 />528 </Show>529 <span class="peach-domain-value">530 {displayDomain() || (entry().url ? entry().url : "add a website url")}531 </span>532 <span class="peach-inline-edit-hint">edit</span>533 </div>534 }535 >536 <div537 class="peach-inline-field-wrap peach-inline-field-wrap--url"538 onFocusOut={(event) => handleEditorFocusOut("url", event)}539 >540 <div class="peach-inline-field-row">541 <input542 class="peach-inline-input"543 type="url"544 placeholder="https://"545 value={draft()}546 onInput={(e) => setDraft(e.currentTarget.value)}547 onKeyDown={onEditKeyDown}548 ref={autoFocus}549 disabled={saving()}550 />551 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />552 </div>553 <div class="peach-additional-urls">554 <Show when={additionalUrlsDraft().length > 0}>555 <span class="peach-label peach-additional-urls-label">also matches</span>556 </Show>557 <For each={additionalUrlsDraft()}>558 {(value, i) => (559 <div class="peach-additional-url-row">560 <input561 class="peach-inline-input"562 type="url"563 placeholder="https://"564 value={value}565 onInput={(e) => setAdditionalUrlsDraft((arr) => {566 const next = [...arr];567 next[i()] = e.currentTarget.value;568 return next;569 })}570 disabled={saving()}571 />572 <button573 class="peach-additional-url-remove"574 type="button"575 aria-label="Remove URL"576 onMouseDown={(e) => e.preventDefault()}577 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}578 disabled={saving()}579 >×</button>580 </div>581 )}582 </For>583 <button584 class="peach-add-field-link peach-additional-urls-add"585 type="button"586 onMouseDown={(e) => e.preventDefault()}587 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}588 disabled={saving()}589 >590 + add another url591 </button>592 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>593 <button594 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"595 type="button"596 title={props.currentTabUrl}597 onMouseDown={(e) => e.preventDefault()}598 onClick={() => addCurrentSite(props.currentTabUrl!)}599 disabled={saving()}600 >601 + add current site602 </button>603 </Show>604 </div>605 </div>606 </Show>607 </div>608 </Show>609610 {}611 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>612 <div613 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"614 onClick={startEditTags}615 title="Click to edit tags"616 >617 <For each={entry().tags!}>618 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}619 </For>620 </div>621 </Show>622623 {}624 <Show when={entry().type === "note"}>625 <div class="peach-entry-note-toolbar-row">626 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />627 </div>628 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">629 <NoteEditorInline630 entryKey={entry().id}631 initialContent={entry().notes ?? ""}632 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}633 onBlur={saveNoteInline}634 autoFocus={false}635 />636 </section>637 </Show>638639 {}640 <Show when={entry().type === "card"}>641 <section class="peach-fields" style="position:relative;z-index:1;">642 {}643 <Show when={entry().last4}>644 <div class="peach-field-card" style="padding-bottom:4px;">645 <span class="peach-label">646 <Show when={entry().network && entry().network !== "unknown"}>647 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>648 </Show>649 {entry().cardNickname || `•••• ${entry().last4}`}650 </span>651 </div>652 </Show>653654 {}655 <div class="peach-field-card">656 <span class="peach-label">cardholder name</span>657 <div class="peach-field-value-row">658 <span class="peach-field-value">{entry().cardholderName || "—"}</span>659 <Show when={entry().cardholderName}>660 <button661 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}662 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}663 aria-label="Copy cardholder name"664 >665 <CopyGlyph /><span class="peach-copy-sheen" />666 </button>667 </Show>668 </div>669 </div>670671 {}672 <div class="peach-field-card">673 <span class="peach-label">card number</span>674 <div class="peach-field-value-row">675 <span class={`peach-field-value peach-card-number`}>676 <Show when={showCardNumber()} fallback={677 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>678 }>679 <span>{entry().cardNumber ?? ""}</span>680 </Show>681 </span>682 <div class="peach-field-actions">683 <button684 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"685 onClick={() => setShowCardNumber(!showCardNumber())}686 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}687 >688 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>689 <EyeClosedGlyph />690 </Show>691 </button>692 <Show when={entry().cardNumber}>693 <button694 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}695 onClick={(e) => {696 const cardNum = entry().cardNumber!;697 if (entry().canary?.webhookUrl) {698 void sendMessage({699 type: "FIRE_CANARY_PING",700 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },701 });702 }703 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);704 }}705 aria-label="Copy card number"706 >707 <CopyGlyph /><span class="peach-copy-sheen" />708 </button>709 </Show>710 </div>711 </div>712 </div>713714 {}715 <Show when={entry().expMonth || entry().expYear}>716 <div class="peach-field-card">717 <span class="peach-label">expiry</span>718 <div class="peach-field-value-row">719 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>720 <button721 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}722 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}723 aria-label="Copy expiry"724 >725 <CopyGlyph /><span class="peach-copy-sheen" />726 </button>727 </div>728 </div>729 </Show>730731 {}732 <Show when={entry().cvc}>733 <div class="peach-field-card">734 <span class="peach-label">CVC</span>735 <div class="peach-field-value-row">736 <span class="peach-field-value">737 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>738 <span>{entry().cvc}</span>739 </Show>740 </span>741 <div class="peach-field-actions">742 <button743 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"744 onClick={() => setShowCardCvc(!showCardCvc())}745 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}746 >747 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>748 <EyeClosedGlyph />749 </Show>750 </button>751 <button752 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}753 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}754 aria-label="Copy CVC"755 >756 <CopyGlyph /><span class="peach-copy-sheen" />757 </button>758 </div>759 </div>760 </div>761 </Show>762 </section>763 </Show>764765 {}766 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>767 <section class="peach-fields" style="position:relative;z-index:1;">768769 {}770 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>771 <span class="peach-label">username</span>772 <Show773 when={editingField() === "username"}774 fallback={775 <div class="peach-field-value-row">776 <span777 class="peach-field-value peach-inline-clickable"778 title="Click to edit"779 onClick={() => startEdit("username", entry().username ?? "")}780 >781 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}782 </span>783 <Show when={entry().username}>784 <button785 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}786 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}787 aria-label="Copy username"788 >789 <CopyGlyph /><span class="peach-copy-sheen" />790 </button>791 </Show>792 </div>793 }794 >795 <div796 class="peach-inline-field-wrap"797 onFocusOut={(event) => handleEditorFocusOut("username", event)}798 >799 <input800 class="peach-inline-input"801 value={draft()}802 onInput={(e) => setDraft(e.currentTarget.value)}803 onKeyDown={onEditKeyDown}804 ref={autoFocus}805 disabled={saving()}806 autocomplete="off"807 />808 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />809 </div>810 </Show>811 </div>812813 {}814 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>815 <span class="peach-label">password</span>816 <Show817 when={editingField() === "password"}818 fallback={819 <div class="peach-field-value-row">820 <span821 class="peach-password-value"822 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}823 style="cursor:pointer;"824 title="Click to edit password"825 onClick={() => startEdit("password", entry().password ?? "")}826 >827 <Show828 when={showPassword()}829 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}830 >831 <span class="peach-password-text">{entry().password ?? ""}</span>832 </Show>833 </span>834 <div class="peach-field-actions">835 <button836 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"837 onClick={() => setShowPassword(!showPassword())}838 aria-label="Reveal password"839 >840 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>841 <EyeClosedGlyph />842 </Show>843 </button>844 <button845 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}846 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}847 aria-label="Copy password"848 >849 <CopyGlyph /><span class="peach-copy-sheen" />850 </button>851 </div>852 </div>853 }854 >855 {}856 <div857 class="peach-inline-field-wrap"858 onFocusOut={(event) => handleEditorFocusOut("password", event)}859 >860 <div class="peach-password-edit-row">861 <input862 class="peach-inline-input"863 type={showPassword() ? "text" : "password"}864 placeholder="paste or type a password"865 value={draft()}866 onInput={(e) => setDraft(e.currentTarget.value)}867 onKeyDown={(e) => {868 if (e.key === "Enter") { e.preventDefault(); void saveField(); }869 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }870 }}871 ref={autoFocus}872 disabled={saving()}873 autocomplete="new-password"874 />875 <button876 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"877 type="button"878 onClick={() => setShowPassword(!showPassword())}879 aria-label={showPassword() ? "Hide" : "Show"}880 >881 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>882 <EyeClosedGlyph />883 </Show>884 </button>885 </div>886 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />887 <button888 class="peach-generate-toggle-link"889 type="button"890 onClick={() => setShowGenerator(!showGenerator())}891 >892 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">893 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />894 <path d="M9.5 2.5H11V1" />895 </svg>896 {showGenerator() ? "hide generator" : "generate"}897 </button>898 <Show when={showGenerator()}>899 <GeneratorDrawer900 open={true}901 compact={true}902 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}903 />904 </Show>905 </div>906 </Show>907 </div>908909 </section>910 </Show>911912 {}913 <Show when={entry().type !== "note" && entry().type !== "card"}>914 <Show915 when={entry().notes || editingField() === "notes"}916 fallback={917 <button918 class="peach-add-field-link peach-entry-add-link"919 onClick={() => startEdit("notes", "")}920 >921 + notes922 </button>923 }924 >925 <div style="position:relative;z-index:1;margin-top:6px;">926 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>927 <Show928 when={editingField() === "notes"}929 fallback={930 <div931 class="peach-note-content peach-inline-clickable"932 style="cursor:text;white-space:pre-wrap;"933 onClick={() => startEdit("notes", entry().notes ?? "")}934 >935 {entry().notes}936 </div>937 }938 >939 <div940 class="peach-inline-field-wrap"941 onFocusOut={(event) => handleEditorFocusOut("notes", event)}942 >943 <textarea944 class="peach-inline-textarea"945 value={draft()}946 onInput={(e) => setDraft(e.currentTarget.value)}947 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}948 ref={(el) => requestAnimationFrame(() => el?.focus())}949 rows={4}950 disabled={saving()}951 />952 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />953 </div>954 </Show>955 </div>956 </Show>957 </Show>958959 {}960 <Show961 when={editingTags()}962 fallback={963 <Show when={!entry().tags || entry().tags!.length === 0}>964 <button965 class="peach-add-field-link peach-entry-add-link"966 onClick={startEditTags}967 >968 + tags969 </button>970 </Show>971 }972 >973 <div974 class="peach-inline-tag-editor"975 onFocusOut={(e) => {976 const next = e.relatedTarget;977 if (next instanceof Node && e.currentTarget.contains(next)) return;978 void saveTags();979 }}980 >981 <span class="peach-label">tags</span>982 <div class="peach-tag-input-wrap">983 <For each={tagsDraft()}>984 {(tag) => (985 <span class="peach-tag-chip">986 {tag}987 <button988 class="peach-tag-chip-remove"989 onMouseDown={(e) => e.preventDefault()}990 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}991 aria-label={`Remove ${tag}`}992 >×</button>993 </span>994 )}995 </For>996 <input997 class="peach-tag-input"998 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}999 value={tagInput()}1000 onInput={(e) => setTagInput(e.currentTarget.value)}1001 onKeyDown={(e) => {1002 if (e.key === "Enter" || e.key === ",") {1003 e.preventDefault();1004 addTag(tagInput());1005 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1006 setTagsDraft(tagsDraft().slice(0, -1));1007 } else if (e.key === "Escape") {1008 e.preventDefault();1009 setEditingTags(false);1010 setTagInput("");1011 }1012 }}1013 ref={(el) => requestAnimationFrame(() => el?.focus())}1014 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1015 />1016 </div>1017 </div>1018 </Show>10191020 {}1021 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1022 <Show1023 when={totpState() || editingField() === "totp"}1024 fallback={1025 <button1026 class="peach-add-field-link peach-entry-add-link"1027 onClick={() => startEdit("totp", "")}1028 >1029 + 2FA1030 </button>1031 }1032 >1033 <section class="peach-totp-band">1034 <Show1035 when={editingField() === "totp"}1036 fallback={1037 <Show when={totpState()}>1038 {(state) => (1039 <>1040 <div1041 class="peach-totp-main"1042 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1043 onDblClick={() => startEdit("totp", entry().totp ?? "")}1044 title="Click to copy · double-click to edit"1045 >1046 <span class="peach-label">2-factor</span>1047 <div class="peach-totp-code-row">1048 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1049 <span class="peach-totp-code-text">1050 {state().code.slice(0, 3)} {state().code.slice(3)}1051 </span>1052 </p>1053 </div>1054 </div>1055 <div class="peach-totp-meta">1056 <span class="peach-label">expires</span>1057 <div class="peach-totp-countdown">{state().remaining}s</div>1058 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1059 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1060 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1061 stroke-dasharray="106.8"1062 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1063 />1064 </svg>1065 </div>1066 </>1067 )}1068 </Show>1069 }1070 >1071 <div1072 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1073 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1074 >1075 <span class="peach-label">2-factor</span>1076 <input1077 class="peach-inline-input"1078 value={draft()}1079 onInput={(e) => setDraft(e.currentTarget.value)}1080 onKeyDown={onEditKeyDown}1081 ref={autoFocus}1082 disabled={saving()}1083 placeholder="otpauth://... or base32 secret"1084 autocomplete="off"1085 />1086 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1087 </div>1088 </Show>1089 </section>1090 </Show>1091 </Show>10921093 {}1094 <Show when={entry().passkey}>1095 {(passkey) => {1096 const currentBrowser = detectBrowser();1097 const crossBrowser =1098 passkey().browser &&1099 currentBrowser !== "unknown" &&1100 passkey().browser !== currentBrowser;1101 const algorithmLabel =1102 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1103 const formatDate = (ts?: number) =>1104 ts ? new Date(ts).toLocaleDateString() : "Never";1105 return (1106 <section1107 class="peach-fields peach-passkey-section"1108 style="position:relative;z-index:1;margin-top:6px;"1109 >1110 <div class="peach-field-card">1111 <span class="peach-label">passkey · {passkey().rpId}</span>1112 <div class="peach-field-value-row">1113 <span class="peach-field-value">{passkey().userName}</span>1114 <span class="peach-passkey-badge">passkey</span>1115 </div>1116 </div>11171118 <div class="peach-field-card">1119 <span class="peach-label">algorithm</span>1120 <div class="peach-field-value-row">1121 <span class="peach-field-value">{algorithmLabel}</span>1122 </div>1123 </div>11241125 <div class="peach-field-card">1126 <span class="peach-label">activity</span>1127 <div class="peach-field-value-row peach-passkey-activity">1128 <span class="peach-field-value">1129 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1130 </span>1131 <span class="peach-passkey-meta">1132 created {formatDate(passkey().createdAt)}1133 </span>1134 <span class="peach-passkey-meta">1135 last used {formatDate(passkey().lastUsedAt)}1136 </span>1137 </div>1138 </div>11391140 <Show when={crossBrowser}>1141 <div class="peach-info-notice peach-info-notice--warn">1142 {`This passkey was created on ${1143 passkey().browser === "chrome" ? "Chrome" : "Firefox"1144 }. To use it on ${1145 currentBrowser === "chrome" ? "Chrome" : "Firefox"1146 }, re-register it on this device.`}1147 </div>1148 </Show>11491150 <div class="peach-info-notice">1151 {passkey().browser === "firefox"1152 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1153 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1154 </div>11551156 <button1157 class="peach-add-field-link peach-entry-add-link"1158 style="color:rgba(220,38,38,0.7);"1159 onClick={async () => {1160 const id = entry().id;1161 await sendMessage({1162 type: "UPDATE_ENTRY",1163 payload: { id, patch: { passkey: undefined } },1164 });1165 await props.onFieldSaved();1166 }}1167 >1168 − remove passkey1169 </button>1170 </section>1171 );1172 }}1173 </Show>11741175 {}1176 <div style="height:4px;" />1177 </>1178 )}1179 </Show>1180 </div>11811182 </>1183 );1184}1185118611871188function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1189 return (1190 <div class="peach-inline-actions">1191 <button1192 class="peach-inline-save-btn"1193 onClick={props.onSave}1194 disabled={props.saving}1195 aria-label="Save"1196 title="Save (Enter)"1197 >1198 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1199 <path d="M1 4.5L4 7.5L10 1.5" />1200 </svg>1201 </button>1202 <button1203 class="peach-inline-cancel-btn"1204 onClick={props.onCancel}1205 disabled={props.saving}1206 aria-label="Cancel"1207 title="Cancel (Esc)"1208 >1209 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1210 <path d="M1 1L8 8M8 1L1 8" />1211 </svg>1212 </button>1213 </div>1214 );1215}12161217function EmptyVaultState() {1218 return (1219 <>1220 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1221 <div class="peach-entry-name">1222 <span class="peach-entry-name-text">1223 <span class="peach-entry-name-char" style="--char-index:0">e</span>1224 <span class="peach-entry-name-char" style="--char-index:1">m</span>1225 <span class="peach-entry-name-char" style="--char-index:2">p</span>1226 <span class="peach-entry-name-char" style="--char-index:3">t</span>1227 <span class="peach-entry-name-char" style="--char-index:4">y</span>1228 </span>1229 </div>1230 </div>1231 <div class="peach-empty-copy">1232 <span class="peach-label">vault</span>1233 <p>Your vault is ready for its first entry.</p>1234 </div>1235 </>1236 );1237}12381239function CopyGlyph() {1240 return (1241 <span class="peach-copy-icon">1242 <svg class="peach-copy-icon-svg" width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1243 <rect x="4" y="4" width="8" height="9" rx="1.5" />1244 <path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4" />1245 </svg>1246 </span>1247 );1248}12491250function EyeClosedGlyph() {1251 return (1252 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1253 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1254 <path d="M3.5 5.5L2.5 7.5" />1255 <path d="M7 6.5V8.5" />1256 <path d="M10.5 5.5L11.5 7.5" />1257 </svg>1258 );1259}12601261function EyeOpenGlyph() {1262 return (1263 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1264 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />1265 <circle cx="7" cy="5" r="1.75" />1266 </svg>1267 );1268}12691270function ChevronGlyph(props: { open: boolean }) {1271 return (1272 <svg1273 width="8"1274 height="8"1275 viewBox="0 0 8 8"1276 fill="none"1277 stroke="currentColor"1278 stroke-width="1.3"1279 stroke-linecap="round"1280 stroke-linejoin="round"1281 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1282 >1283 <path d="M2 1L6 4L2 7" />1284 </svg>1285 );1286}12871288function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1289 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1290 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1291 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1292 const [testResult, setTestResult] = createSignal<string | null>(null);1293 const [testing, setTesting] = createSignal(false);12941295 async function save() {1296 const url = webhookUrl().trim();1297 const nextLabel = label().trim() || undefined;1298 const canary = enabled() && url1299 ? {1300 webhookUrl: url,1301 label: nextLabel,1302 lastPinged: props.entry.canary?.lastPinged,1303 pingCount: props.entry.canary?.pingCount,1304 }1305 : undefined;1306 await sendMessage({1307 type: "UPDATE_ENTRY",1308 payload: { id: props.entry.id, patch: { canary } },1309 });1310 await props.onSaved();1311 }13121313 async function testPing() {1314 const url = webhookUrl().trim();1315 if (!url) return;1316 setTesting(true);1317 setTestResult(null);1318 const res = await sendMessage({1319 type: "FIRE_CANARY_PING",1320 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1321 }) as { ok: boolean; error?: string };1322 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1323 setTesting(false);1324 }13251326 return (1327 <div class="peach-canary-section">1328 <div class="peach-field-value-row">1329 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1330 <button1331 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1332 onClick={() => { setEnabled(!enabled()); void save(); }}1333 aria-label="Toggle canary"1334 >1335 <span class="peach-toggle-knob" />1336 </button>1337 </div>1338 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1339 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1340 </p>1341 <Show when={enabled()}>1342 <div class="peach-inline-field-wrap" style="margin-top:8px;">1343 <input1344 class="peach-inline-input"1345 placeholder="https://hooks.zapier.com/..."1346 value={webhookUrl()}1347 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1348 onBlur={() => void save()}1349 />1350 <input1351 class="peach-inline-input"1352 placeholder="label (optional, e.g. AWS decoy)"1353 value={label()}1354 onInput={(e) => setLabel(e.currentTarget.value)}1355 onBlur={() => void save()}1356 />1357 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1358 <button1359 class="peach-btn peach-btn--ghost peach-btn--sm"1360 onClick={() => void testPing()}1361 disabled={testing() || !webhookUrl().trim()}1362 >1363 {testing() ? "sending..." : "test ping"}1364 </button>1365 <Show when={testResult()}>1366 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1367 </Show>1368 </div>1369 <Show when={props.entry.canary?.lastPinged}>1370 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1371 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1372 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1373 </p>1374 </Show>1375 </div>1376 </Show>1377 </div>1378 );1379}