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" | "email" | "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 50 const [showAdvanced, setShowAdvanced] = createSignal(false);51 let lastSavedNote: string | null = null;5253 function noteExec(cmd: string, val?: string) {54 document.execCommand(cmd, false, val);55 noteEditorEl()?.focus();56 }5758 async function saveNoteInline(html: string) {59 const entry = props.entry;60 if (!entry) return;61 const currentStored = entry.notes ?? "";62 if (html === currentStored) return;63 if (lastSavedNote !== null && html === lastSavedNote) return;64 lastSavedNote = html;65 setSaving(true);66 try {67 await sendMessage({68 type: "UPDATE_ENTRY",69 payload: { id: entry.id, patch: { notes: html } },70 });71 triggerRing();72 await props.onFieldSaved();73 } catch {74 lastSavedNote = null;75 } finally {76 setSaving(false);77 }78 }7980 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81 onCleanup(() => window.clearInterval(interval));8283 const displayDomain = createMemo(() =>84 props.entry?.url ? getDisplayDomain(props.entry.url) : ""85 );8687 createEffect(() => {88 const domain = displayDomain();89 setFaviconSrc(domain ? faviconUrl(domain) : null);90 });9192 93 94 95 96 97 let lastEntryId: string | null = null;98 createEffect(() => {99 const id = props.entry?.id ?? null;100 if (id === lastEntryId) return;101 lastEntryId = id;102 setConfirmDelete(false);103 setEditingField(null);104 setEditingTags(false);105 setShowAdvanced(false);106 });107108 const totpState = createMemo(() => {109 void totpTick();110 if (!props.entry?.totp) return null;111 return generateTOTP(props.entry.totp);112 });113114 let prevTotpCode: string | undefined;115 createEffect(() => {116 const state = totpState();117 if (!state) { prevTotpCode = undefined; return; }118 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {119 setTotpHot(true);120 window.setTimeout(() => setTotpHot(false), 3000);121 }122 prevTotpCode = state.code;123 });124125 const totpProgress = createMemo(() => {126 const state = totpState();127 if (!state) return 0;128 return ((state.remaining - 1) / 29) * 100;129 });130131 createEffect(() => {132 const shell = document.getElementById("hd-shell");133 if (!shell) return;134135 const state = totpState();136 if (!state) {137 delete shell.dataset.totpActive;138 shell.style.removeProperty("--peach-totp-progress");139 return;140 }141142 shell.dataset.totpActive = "true";143 shell.style.setProperty(144 "--peach-totp-progress",145 `${Math.max(0, totpProgress())}%`146 );147148 onCleanup(() => {149 delete shell.dataset.totpActive;150 shell.style.removeProperty("--peach-totp-progress");151 });152 });153154 const titleSizeClass = createMemo(() => {155 const length = props.entry?.title.length ?? 0;156 if (length >= 28) return "peach-entry-name--xxl";157 if (length >= 20) return "peach-entry-name--xl";158 if (length >= 14) return "peach-entry-name--lg";159 return "";160 });161 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);162163 164165 function startEdit(field: EditableField, value: string) {166 setEditingField(field);167 setDraft(value);168 setShowGenerator(false);169 setConfirmDelete(false);170 if (field === "url") {171 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);172 }173 }174175 176 177 178 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {179 const c = candidate.trim();180 if (!c) return true;181 if (primary.trim() === c) return true;182 return others.some((u) => u.trim() === c);183 }184185 function addCurrentSite(url: string) {186 if (!url) return;187 if (!draft().trim()) {188 setDraft(url);189 return;190 }191 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;192 setAdditionalUrlsDraft((arr) => [...arr, url]);193 }194195 async function saveField() {196 const field = editingField();197 const entry = props.entry;198 if (!field || !entry) return;199 200 let value = field === "password" ? draft() : draft().trim();201 if (field === "totp" && value.startsWith("otpauth://")) {202 const parsed = parseOtpauthURI(value);203 value = parsed?.secret ?? value;204 }205 if (field === "password" && entry.type !== "note" && !value) {206 return;207 }208 setSaving(true);209 try {210 const patch: Partial<VaultEntry> = { [field]: value };211 if (field === "url") {212 213 patch.additionalUrls = additionalUrlsDraft();214 }215 await sendMessage({216 type: "UPDATE_ENTRY",217 payload: { id: entry.id, patch },218 });219 triggerRing();220 setEditingField(null);221 setShowGenerator(false);222 await props.onFieldSaved();223 } catch {224 225 } finally {226 setSaving(false);227 }228 }229230 function startEditTags() {231 setTagsDraft([...(props.entry?.tags ?? [])]);232 setTagInput("");233 setEditingTags(true);234 }235236 function addTag(value: string) {237 const n = value.toLowerCase().trim();238 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;239 setTagsDraft([...tagsDraft(), n]);240 setTagInput("");241 }242243 async function saveTags() {244 if (tagInput()) addTag(tagInput());245 const entry = props.entry;246 if (!entry) return;247 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;248 setEditingTags(false);249 setSaving(true);250 try {251 await sendMessage({252 type: "UPDATE_ENTRY",253 payload: { id: entry.id, patch: { tags: next } },254 });255 triggerRing();256 await props.onFieldSaved();257 } catch {258 259 } finally {260 setSaving(false);261 }262 }263264 function cancelEdit() {265 setEditingField(null);266 setDraft("");267 setShowGenerator(false);268 }269270 271 272 273 274 async function addOtherIdentity(target: "username" | "email") {275 const entry = props.entry;276 if (!entry) return;277 const current = editingField();278 const value = draft().trim();279 if (current === "username" && target === "email") {280 281 if (value !== (entry.username ?? "")) {282 setSaving(true);283 try {284 await sendMessage({285 type: "UPDATE_ENTRY",286 payload: { id: entry.id, patch: { username: value } },287 });288 await props.onFieldSaved();289 } finally {290 setSaving(false);291 }292 }293 startEdit("email", "");294 return;295 }296 if (current === "username" && target === "username") {297 298 299 if (value.includes("@") && !entry.email) {300 setSaving(true);301 try {302 await sendMessage({303 type: "UPDATE_ENTRY",304 payload: { id: entry.id, patch: { email: value, username: "" } },305 });306 await props.onFieldSaved();307 } finally {308 setSaving(false);309 }310 }311 startEdit("username", "");312 return;313 }314 if (current === "email" && target === "username") {315 316 if (value !== (entry.email ?? "")) {317 setSaving(true);318 try {319 await sendMessage({320 type: "UPDATE_ENTRY",321 payload: { id: entry.id, patch: { email: value } },322 });323 await props.onFieldSaved();324 } finally {325 setSaving(false);326 }327 }328 startEdit("username", "");329 return;330 }331 }332333 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {334 switch (field) {335 case "title":336 return entry.title;337 case "url":338 return entry.url ?? "";339 case "username":340 return entry.username ?? "";341 case "email":342 return entry.email ?? "";343 case "password":344 return entry.password ?? "";345 case "notes":346 return entry.notes ?? "";347 case "totp":348 return entry.totp ?? "";349 }350 }351352 function handleEditorFocusOut(353 field: Exclude<EditableField, null>,354 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }355 ) {356 const nextTarget = event.relatedTarget;357 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;358359 const entry = props.entry;360 if (!entry) return;361362 const currentValue = fieldValue(entry, field);363 const nextValue = field === "password" ? draft() : draft().trim();364 if (nextValue === currentValue) {365 cancelEdit();366 }367 }368369 async function savePassword(password: string) {370 const entry = props.entry;371 if (!entry) return;372 setSaving(true);373 try {374 await sendMessage({375 type: "UPDATE_ENTRY",376 payload: { id: entry.id, patch: { password } },377 });378 triggerRing();379 setShowGenerator(false);380 await props.onFieldSaved();381 } catch {382 383 } finally {384 setSaving(false);385 }386 }387388 389390 async function copyField(text: string, label: string, button?: HTMLButtonElement) {391 const ok = await popupCopy(text, label);392 if (ok) {393 const entry = props.entry;394 if (entry?.canary?.webhookUrl) {395 const trigger =396 label === "password"397 ? "password_copied"398 : label === "username"399 ? "username_copied"400 : undefined;401 if (trigger) {402 void sendMessage({403 type: "FIRE_CANARY_PING",404 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },405 });406 }407 }408 triggerRing();409 triggerPipPulse();410 setCopied(null);411 requestAnimationFrame(() => setCopied(label));412 if (button) {413 button.classList.remove("is-copied", "is-retreat");414 void button.offsetWidth;415 const drain = document.createElement("span");416 drain.className = "peach-copy-drain";417 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";418 drain.appendChild(bar);419 button.appendChild(drain);420 window.setTimeout(() => {421 button.classList.remove("is-copied");422 button.classList.add("is-retreat");423 drain.remove();424 window.setTimeout(() => button.classList.remove("is-retreat"), 160);425 }, 1800);426 }427 window.setTimeout(() => setCopied(null), 1800);428 }429 }430431 432433 createEffect(() => {434 if (!props.entry) return;435 const handler = (e: KeyboardEvent) => {436 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;437 if (inInput) return;438 switch (e.key) {439 case "c": e.preventDefault();440 if (props.entry?.password) void copyField(props.entry.password, "password"); break;441 case "u": e.preventDefault();442 if (props.entry?.username) void copyField(props.entry.username, "username"); break;443 case "Escape": e.preventDefault(); props.onEscape?.(); break;444 }445 };446 window.addEventListener("keydown", handler);447 onCleanup(() => window.removeEventListener("keydown", handler));448 });449450 451452 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {453 requestAnimationFrame(() => { el.focus(); el.select?.(); });454 }455456 function onEditKeyDown(e: KeyboardEvent) {457 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {458 e.preventDefault();459 void saveField();460 }461 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }462 }463464 return (465 <>466 <div467 class="peach-ghost"468 aria-hidden="true"469 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;"470 >471 {props.entry?.title || "empty"}472 </div>473474 <div class="peach-stage" style="position:relative;">475 <Show when={props.entry} fallback={<EmptyVaultState />}>476 {(entry) => (477 <>478 {}479 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>480 <div class="peach-breach-badge">481 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">482 <path d="M6 1L11 10H1L6 1z" />483 <line x1="6" y1="5" x2="6" y2="7.5" />484 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />485 </svg>486 <span style="flex:1">found in a known data breach</span>487 <button488 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"489 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}490 >491 Change password492 </button>493 </div>494 </Show>495496 {}497 <div class="peach-entry-header">498 <div class="peach-entry-header-name">499 <Show500 when={editingField() === "title"}501 fallback={502 <button503 class="peach-entry-name-button"504 title="Click to edit title"505 onClick={() => startEdit("title", entry().title)}506 >507 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>508 <span class="peach-entry-name-text">509 <Show510 when={useAnimatedTitle()}511 fallback={entry().title}512 >513 <For each={entry().title.split("")}>514 {(ch, i) => (515 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>516 {ch === " " ? "\u00A0" : ch}517 </span>518 )}519 </For>520 </Show>521 </span>522 </h1>523 </button>524 }525 >526 <div527 class="peach-inline-title-wrap"528 onFocusOut={(event) => handleEditorFocusOut("title", event)}529 >530 <input531 class="peach-inline-title-input"532 value={draft()}533 onInput={(e) => setDraft(e.currentTarget.value)}534 onKeyDown={onEditKeyDown}535 ref={autoFocus}536 disabled={saving()}537 />538 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />539 </div>540 </Show>541 </div>542543 {}544 <Show when={editingField() !== "title"}>545 <Show546 when={confirmDelete()}547 fallback={548 <button549 class="peach-entry-trash-btn"550 onClick={() => setConfirmDelete(true)}551 aria-label="Move to trash"552 title="Delete entry"553 >554 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">555 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />556 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />557 </svg>558 </button>559 }560 >561 <div class="peach-entry-trash-confirm">562 <span class="peach-entry-trash-confirm-label">trash?</span>563 <button564 class="peach-btn peach-btn--sm peach-btn--danger"565 style="font-size:10px;padding:2px 8px;min-height:22px;"566 onClick={() => { setConfirmDelete(false); props.onDelete(); }}567 >568 yes569 </button>570 <button571 class="peach-btn peach-btn--ghost peach-btn--sm"572 style="font-size:10px;padding:2px 8px;min-height:22px;"573 onClick={() => setConfirmDelete(false)}574 >575 no576 </button>577 </div>578 </Show>579 </Show>580 </div>581582 {}583 <Show when={entry().type !== "note" && entry().type !== "card"}>584 <div class="peach-domain" style="position:relative;z-index:1;">585 <Show586 when={editingField() === "url"}587 fallback={588 <div589 class="peach-domain-row peach-inline-clickable"590 title="Click to edit URL"591 onClick={() => startEdit("url", entry().url ?? "")}592 >593 <Show when={faviconSrc()}>594 <img595 src={faviconSrc()!}596 width="16"597 height="16"598 alt=""599 style="border-radius:3px;margin-right:5px;flex-shrink:0;"600 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}601 />602 </Show>603 <span class="peach-domain-value">604 {displayDomain() || (entry().url ? entry().url : "add a website url")}605 </span>606 <span class="peach-inline-edit-hint">edit</span>607 </div>608 }609 >610 <div611 class="peach-inline-field-wrap peach-inline-field-wrap--url"612 onFocusOut={(event) => handleEditorFocusOut("url", event)}613 >614 <div class="peach-inline-field-row">615 <input616 class="peach-inline-input"617 type="url"618 placeholder="https://"619 value={draft()}620 onInput={(e) => setDraft(e.currentTarget.value)}621 onKeyDown={onEditKeyDown}622 ref={autoFocus}623 disabled={saving()}624 />625 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />626 </div>627 <div class="peach-additional-urls">628 <Show when={additionalUrlsDraft().length > 0}>629 <span class="peach-label peach-additional-urls-label">also matches</span>630 </Show>631 <For each={additionalUrlsDraft()}>632 {(value, i) => (633 <div class="peach-additional-url-row">634 <input635 class="peach-inline-input"636 type="url"637 placeholder="https://"638 value={value}639 onInput={(e) => setAdditionalUrlsDraft((arr) => {640 const next = [...arr];641 next[i()] = e.currentTarget.value;642 return next;643 })}644 disabled={saving()}645 />646 <button647 class="peach-additional-url-remove"648 type="button"649 aria-label="Remove URL"650 onMouseDown={(e) => e.preventDefault()}651 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}652 disabled={saving()}653 >×</button>654 </div>655 )}656 </For>657 <button658 class="peach-add-field-link peach-additional-urls-add"659 type="button"660 onMouseDown={(e) => e.preventDefault()}661 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}662 disabled={saving()}663 >664 + add another url665 </button>666 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>667 <button668 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"669 type="button"670 title={props.currentTabUrl}671 onMouseDown={(e) => e.preventDefault()}672 onClick={() => addCurrentSite(props.currentTabUrl!)}673 disabled={saving()}674 >675 + add current site676 </button>677 </Show>678 </div>679 </div>680 </Show>681 </div>682 </Show>683684 {}685 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>686 <div687 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"688 onClick={startEditTags}689 title="Click to edit tags"690 >691 <For each={entry().tags!}>692 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}693 </For>694 </div>695 </Show>696697 {}698 <Show when={entry().type === "note"}>699 <div class="peach-entry-note-toolbar-row">700 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />701 </div>702 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">703 <NoteEditorInline704 entryKey={entry().id}705 initialContent={entry().notes ?? ""}706 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}707 onBlur={saveNoteInline}708 autoFocus={false}709 />710 </section>711 </Show>712713 {}714 <Show when={entry().type === "card"}>715 <section class="peach-fields" style="position:relative;z-index:1;">716 {}717 <Show when={entry().last4}>718 <div class="peach-field-card" style="padding-bottom:4px;">719 <span class="peach-label">720 <Show when={entry().network && entry().network !== "unknown"}>721 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>722 </Show>723 {entry().cardNickname || `•••• ${entry().last4}`}724 </span>725 </div>726 </Show>727728 {}729 <div class="peach-field-card">730 <span class="peach-label">cardholder name</span>731 <div class="peach-field-value-row">732 <span class="peach-field-value">{entry().cardholderName || "—"}</span>733 <Show when={entry().cardholderName}>734 <button735 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}736 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}737 aria-label="Copy cardholder name"738 >739 <CopyGlyph /><span class="peach-copy-sheen" />740 </button>741 </Show>742 </div>743 </div>744745 {}746 <div class="peach-field-card">747 <span class="peach-label">card number</span>748 <div class="peach-field-value-row">749 <span class={`peach-field-value peach-card-number`}>750 <Show when={showCardNumber()} fallback={751 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>752 }>753 <span>{entry().cardNumber ?? ""}</span>754 </Show>755 </span>756 <div class="peach-field-actions">757 <button758 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"759 onClick={() => setShowCardNumber(!showCardNumber())}760 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}761 >762 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>763 <EyeClosedGlyph />764 </Show>765 </button>766 <Show when={entry().cardNumber}>767 <button768 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}769 onClick={(e) => {770 const cardNum = entry().cardNumber!;771 if (entry().canary?.webhookUrl) {772 void sendMessage({773 type: "FIRE_CANARY_PING",774 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },775 });776 }777 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);778 }}779 aria-label="Copy card number"780 >781 <CopyGlyph /><span class="peach-copy-sheen" />782 </button>783 </Show>784 </div>785 </div>786 </div>787788 {}789 <Show when={entry().expMonth || entry().expYear}>790 <div class="peach-field-card">791 <span class="peach-label">expiry</span>792 <div class="peach-field-value-row">793 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>794 <button795 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}796 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}797 aria-label="Copy expiry"798 >799 <CopyGlyph /><span class="peach-copy-sheen" />800 </button>801 </div>802 </div>803 </Show>804805 {}806 <Show when={entry().cvc}>807 <div class="peach-field-card">808 <span class="peach-label">CVC</span>809 <div class="peach-field-value-row">810 <span class="peach-field-value">811 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>812 <span>{entry().cvc}</span>813 </Show>814 </span>815 <div class="peach-field-actions">816 <button817 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"818 onClick={() => setShowCardCvc(!showCardCvc())}819 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}820 >821 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>822 <EyeClosedGlyph />823 </Show>824 </button>825 <button826 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}827 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}828 aria-label="Copy CVC"829 >830 <CopyGlyph /><span class="peach-copy-sheen" />831 </button>832 </div>833 </div>834 </div>835 </Show>836 </section>837 </Show>838839 {}840 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>841 <section class="peach-fields" style="position:relative;z-index:1;">842843 {844845}846 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>847 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>848 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>849 <Show850 when={editingField() === "username"}851 fallback={852 <div class="peach-field-value-row">853 <span854 class="peach-field-value peach-inline-clickable"855 title="Click to edit"856 onClick={() => startEdit("username", entry().username ?? "")}857 >858 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}859 </span>860 <Show when={entry().username}>861 <button862 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}863 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}864 aria-label="Copy username"865 >866 <CopyGlyph /><span class="peach-copy-sheen" />867 </button>868 </Show>869 </div>870 }871 >872 <div873 class="peach-inline-field-wrap"874 onFocusOut={(event) => handleEditorFocusOut("username", event)}875 >876 <input877 class="peach-inline-input"878 value={draft()}879 onInput={(e) => setDraft(e.currentTarget.value)}880 onKeyDown={onEditKeyDown}881 ref={autoFocus}882 disabled={saving()}883 autocomplete="off"884 />885 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />886 {887888889}890 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>891 <button892 class="peach-add-field-btn"893 type="button"894 onMouseDown={(e) => e.preventDefault()}895 onClick={() => {896 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);897 const hasSeparateEmail = !!entry().email;898 if (isEmailShape && !hasSeparateEmail) {899 void addOtherIdentity("username");900 } else {901 void addOtherIdentity("email");902 }903 }}904 >905 {(() => {906 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);907 const hasSeparateEmail = !!entry().email;908 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";909 })()}910 </button>911 </Show>912 </div>913 </Show>914 </div>915 </Show>916917 {918}919 <Show when={entry().email || editingField() === "email"}>920 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>921 <span class="peach-label">email</span>922 <Show923 when={editingField() === "email"}924 fallback={925 <div class="peach-field-value-row">926 <span927 class="peach-field-value peach-inline-clickable"928 title="Click to edit"929 onClick={() => startEdit("email", entry().email ?? "")}930 >931 {entry().email}932 </span>933 <Show when={entry().email}>934 <button935 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}936 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}937 aria-label="Copy email"938 >939 <CopyGlyph /><span class="peach-copy-sheen" />940 </button>941 </Show>942 </div>943 }944 >945 <div946 class="peach-inline-field-wrap"947 onFocusOut={(event) => handleEditorFocusOut("email", event)}948 >949 <input950 class="peach-inline-input"951 value={draft()}952 onInput={(e) => setDraft(e.currentTarget.value)}953 onKeyDown={onEditKeyDown}954 ref={autoFocus}955 disabled={saving()}956 autocomplete="email"957 type="email"958 />959 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />960 <Show when={!entry().username || entry().username?.includes("@")}>961 <button962 class="peach-add-field-btn"963 type="button"964 onMouseDown={(e) => e.preventDefault()}965 onClick={() => void addOtherIdentity("username")}966 >967 + add username968 </button>969 </Show>970 </div>971 </Show>972 </div>973 </Show>974975 {}976 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>977 <span class="peach-label">password</span>978 <Show979 when={editingField() === "password"}980 fallback={981 <div class="peach-field-value-row">982 <span983 class="peach-password-value"984 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}985 style="cursor:pointer;"986 title="Click to edit password"987 onClick={() => startEdit("password", entry().password ?? "")}988 >989 <Show990 when={showPassword()}991 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}992 >993 <span class="peach-password-text">{entry().password ?? ""}</span>994 </Show>995 </span>996 <div class="peach-field-actions">997 <button998 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"999 onClick={() => setShowPassword(!showPassword())}1000 aria-label="Reveal password"1001 >1002 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1003 <EyeClosedGlyph />1004 </Show>1005 </button>1006 <button1007 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1008 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1009 aria-label="Copy password"1010 >1011 <CopyGlyph /><span class="peach-copy-sheen" />1012 </button>1013 </div>1014 </div>1015 }1016 >1017 {}1018 <div1019 class="peach-inline-field-wrap"1020 onFocusOut={(event) => handleEditorFocusOut("password", event)}1021 >1022 <div class="peach-password-edit-row">1023 <input1024 class="peach-inline-input"1025 type={showPassword() ? "text" : "password"}1026 placeholder="paste or type a password"1027 value={draft()}1028 onInput={(e) => setDraft(e.currentTarget.value)}1029 onKeyDown={(e) => {1030 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1031 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1032 }}1033 ref={autoFocus}1034 disabled={saving()}1035 autocomplete="new-password"1036 />1037 <button1038 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1039 type="button"1040 onClick={() => setShowPassword(!showPassword())}1041 aria-label={showPassword() ? "Hide" : "Show"}1042 >1043 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1044 <EyeClosedGlyph />1045 </Show>1046 </button>1047 </div>1048 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1049 <button1050 class="peach-generate-toggle-link"1051 type="button"1052 onClick={() => setShowGenerator(!showGenerator())}1053 >1054 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1055 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1056 <path d="M9.5 2.5H11V1" />1057 </svg>1058 {showGenerator() ? "hide generator" : "generate"}1059 </button>1060 <Show when={showGenerator()}>1061 <GeneratorDrawer1062 open={true}1063 compact={true}1064 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1065 />1066 </Show>1067 </div>1068 </Show>1069 </div>10701071 </section>1072 </Show>10731074 {}1075 <Show when={entry().type !== "note" && entry().type !== "card"}>1076 <Show1077 when={entry().notes || editingField() === "notes"}1078 fallback={1079 <button1080 class="peach-add-field-link peach-entry-add-link"1081 onClick={() => startEdit("notes", "")}1082 >1083 + notes1084 </button>1085 }1086 >1087 <div style="position:relative;z-index:1;margin-top:6px;">1088 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1089 <Show1090 when={editingField() === "notes"}1091 fallback={1092 <div1093 class="peach-note-content peach-inline-clickable"1094 style="cursor:text;white-space:pre-wrap;"1095 onClick={() => startEdit("notes", entry().notes ?? "")}1096 >1097 {entry().notes}1098 </div>1099 }1100 >1101 <div1102 class="peach-inline-field-wrap"1103 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1104 >1105 <textarea1106 class="peach-inline-textarea"1107 value={draft()}1108 onInput={(e) => setDraft(e.currentTarget.value)}1109 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1110 ref={(el) => requestAnimationFrame(() => el?.focus())}1111 rows={4}1112 disabled={saving()}1113 />1114 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1115 </div>1116 </Show>1117 </div>1118 </Show>1119 </Show>11201121 {}1122 <Show1123 when={editingTags()}1124 fallback={1125 <Show when={!entry().tags || entry().tags!.length === 0}>1126 <button1127 class="peach-add-field-link peach-entry-add-link"1128 onClick={startEditTags}1129 >1130 + tags1131 </button>1132 </Show>1133 }1134 >1135 <div1136 class="peach-inline-tag-editor"1137 onFocusOut={(e) => {1138 const next = e.relatedTarget;1139 if (next instanceof Node && e.currentTarget.contains(next)) return;1140 void saveTags();1141 }}1142 >1143 <span class="peach-label">tags</span>1144 <div class="peach-tag-input-wrap">1145 <For each={tagsDraft()}>1146 {(tag) => (1147 <span class="peach-tag-chip">1148 {tag}1149 <button1150 class="peach-tag-chip-remove"1151 onMouseDown={(e) => e.preventDefault()}1152 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1153 aria-label={`Remove ${tag}`}1154 >×</button>1155 </span>1156 )}1157 </For>1158 <input1159 class="peach-tag-input"1160 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1161 value={tagInput()}1162 onInput={(e) => setTagInput(e.currentTarget.value)}1163 onKeyDown={(e) => {1164 if (e.key === "Enter" || e.key === ",") {1165 e.preventDefault();1166 addTag(tagInput());1167 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1168 setTagsDraft(tagsDraft().slice(0, -1));1169 } else if (e.key === "Escape") {1170 e.preventDefault();1171 setEditingTags(false);1172 setTagInput("");1173 }1174 }}1175 ref={(el) => requestAnimationFrame(() => el?.focus())}1176 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1177 />1178 </div>1179 </div>1180 </Show>11811182 {}1183 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1184 <Show1185 when={totpState() || editingField() === "totp"}1186 fallback={1187 <button1188 class="peach-add-field-link peach-entry-add-link"1189 onClick={() => startEdit("totp", "")}1190 >1191 + 2FA1192 </button>1193 }1194 >1195 <section class="peach-totp-band">1196 <Show1197 when={editingField() === "totp"}1198 fallback={1199 <Show when={totpState()}>1200 {(state) => (1201 <>1202 <div1203 class="peach-totp-main"1204 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1205 onDblClick={() => startEdit("totp", entry().totp ?? "")}1206 title="Click to copy · double-click to edit"1207 >1208 <span class="peach-label">2-factor</span>1209 <div class="peach-totp-code-row">1210 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1211 <span class="peach-totp-code-text">1212 {state().code.slice(0, 3)} {state().code.slice(3)}1213 </span>1214 </p>1215 </div>1216 </div>1217 <div class="peach-totp-meta">1218 <span class="peach-label">expires</span>1219 <div class="peach-totp-countdown">{state().remaining}s</div>1220 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1221 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1222 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1223 stroke-dasharray="106.8"1224 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1225 />1226 </svg>1227 </div>1228 </>1229 )}1230 </Show>1231 }1232 >1233 <div1234 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1235 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1236 >1237 <span class="peach-label">2-factor</span>1238 <input1239 class="peach-inline-input"1240 value={draft()}1241 onInput={(e) => setDraft(e.currentTarget.value)}1242 onKeyDown={onEditKeyDown}1243 ref={autoFocus}1244 disabled={saving()}1245 placeholder="otpauth://... or base32 secret"1246 autocomplete="off"1247 />1248 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1249 </div>1250 </Show>1251 </section>1252 </Show>1253 </Show>12541255 {}1256 <Show when={entry().type === "login"}>1257 <button1258 class="peach-add-field-link peach-entry-add-link"1259 onClick={() => setShowAdvanced((v) => !v)}1260 aria-expanded={showAdvanced()}1261 >1262 {showAdvanced() ? "− Advanced" : "+ Advanced"}1263 </button>1264 <Show when={showAdvanced()}>1265 <Show when={!isPasskeyOnly(entry())}>1266 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1267 </Show>12681269 <Show when={entry().passkey}>1270 {(passkey) => {1271 const currentBrowser = detectBrowser();1272 const crossBrowser =1273 passkey().browser &&1274 currentBrowser !== "unknown" &&1275 passkey().browser !== currentBrowser;1276 const algorithmLabel =1277 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1278 const formatDate = (ts?: number) =>1279 ts ? new Date(ts).toLocaleDateString() : "Never";1280 return (1281 <section1282 class="peach-fields peach-passkey-section"1283 style="position:relative;z-index:1;margin-top:6px;"1284 >1285 <div class="peach-field-card">1286 <span class="peach-label">passkey · {passkey().rpId}</span>1287 <div class="peach-field-value-row">1288 <span class="peach-field-value">{passkey().userName}</span>1289 <span class="peach-passkey-badge">passkey</span>1290 </div>1291 </div>12921293 <div class="peach-field-card">1294 <span class="peach-label">algorithm</span>1295 <div class="peach-field-value-row">1296 <span class="peach-field-value">{algorithmLabel}</span>1297 </div>1298 </div>12991300 <div class="peach-field-card">1301 <span class="peach-label">activity</span>1302 <div class="peach-field-value-row peach-passkey-activity">1303 <span class="peach-field-value">1304 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1305 </span>1306 <span class="peach-passkey-meta">1307 created {formatDate(passkey().createdAt)}1308 </span>1309 <span class="peach-passkey-meta">1310 last used {formatDate(passkey().lastUsedAt)}1311 </span>1312 </div>1313 </div>13141315 <Show when={crossBrowser}>1316 <div class="peach-info-notice peach-info-notice--warn">1317 {`This passkey was created on ${1318 passkey().browser === "chrome" ? "Chrome" : "Firefox"1319 }. To use it on ${1320 currentBrowser === "chrome" ? "Chrome" : "Firefox"1321 }, re-register it on this device.`}1322 </div>1323 </Show>13241325 <div class="peach-info-notice">1326 {passkey().browser === "firefox"1327 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1328 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1329 </div>13301331 <button1332 class="peach-add-field-link peach-entry-add-link"1333 style="color:rgba(220,38,38,0.7);"1334 onClick={async () => {1335 const id = entry().id;1336 await sendMessage({1337 type: "UPDATE_ENTRY",1338 payload: { id, patch: { passkey: undefined } },1339 });1340 await props.onFieldSaved();1341 }}1342 >1343 − remove passkey1344 </button>1345 </section>1346 );1347 }}1348 </Show>1349 </Show>1350 </Show>13511352 {}1353 <div style="height:4px;" />1354 </>1355 )}1356 </Show>1357 </div>13581359 </>1360 );1361}1362136313641365function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1366 return (1367 <div class="peach-inline-actions">1368 <button1369 class="peach-inline-save-btn"1370 onClick={props.onSave}1371 disabled={props.saving}1372 aria-label="Save"1373 title="Save (Enter)"1374 >1375 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1376 <path d="M1 4.5L4 7.5L10 1.5" />1377 </svg>1378 </button>1379 <button1380 class="peach-inline-cancel-btn"1381 onClick={props.onCancel}1382 disabled={props.saving}1383 aria-label="Cancel"1384 title="Cancel (Esc)"1385 >1386 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1387 <path d="M1 1L8 8M8 1L1 8" />1388 </svg>1389 </button>1390 </div>1391 );1392}13931394function EmptyVaultState() {1395 return (1396 <>1397 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1398 <div class="peach-entry-name">1399 <span class="peach-entry-name-text">1400 <span class="peach-entry-name-char" style="--char-index:0">e</span>1401 <span class="peach-entry-name-char" style="--char-index:1">m</span>1402 <span class="peach-entry-name-char" style="--char-index:2">p</span>1403 <span class="peach-entry-name-char" style="--char-index:3">t</span>1404 <span class="peach-entry-name-char" style="--char-index:4">y</span>1405 </span>1406 </div>1407 </div>1408 <div class="peach-empty-copy">1409 <span class="peach-label">vault</span>1410 <p>Your vault is ready for its first entry.</p>1411 </div>1412 </>1413 );1414}14151416function CopyGlyph() {1417 return (1418 <span class="peach-copy-icon">1419 <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">1420 <rect x="4" y="4" width="8" height="9" rx="1.5" />1421 <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" />1422 </svg>1423 </span>1424 );1425}14261427function EyeClosedGlyph() {1428 return (1429 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1430 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1431 <path d="M3.5 5.5L2.5 7.5" />1432 <path d="M7 6.5V8.5" />1433 <path d="M10.5 5.5L11.5 7.5" />1434 </svg>1435 );1436}14371438function EyeOpenGlyph() {1439 return (1440 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1441 <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" />1442 <circle cx="7" cy="5" r="1.75" />1443 </svg>1444 );1445}14461447function ChevronGlyph(props: { open: boolean }) {1448 return (1449 <svg1450 width="8"1451 height="8"1452 viewBox="0 0 8 8"1453 fill="none"1454 stroke="currentColor"1455 stroke-width="1.3"1456 stroke-linecap="round"1457 stroke-linejoin="round"1458 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1459 >1460 <path d="M2 1L6 4L2 7" />1461 </svg>1462 );1463}14641465function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1466 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1467 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1468 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1469 const [testResult, setTestResult] = createSignal<string | null>(null);1470 const [testing, setTesting] = createSignal(false);14711472 async function save() {1473 const url = webhookUrl().trim();1474 const nextLabel = label().trim() || undefined;1475 const canary = enabled() && url1476 ? {1477 webhookUrl: url,1478 label: nextLabel,1479 lastPinged: props.entry.canary?.lastPinged,1480 pingCount: props.entry.canary?.pingCount,1481 }1482 : undefined;1483 await sendMessage({1484 type: "UPDATE_ENTRY",1485 payload: { id: props.entry.id, patch: { canary } },1486 });1487 await props.onSaved();1488 }14891490 async function testPing() {1491 const url = webhookUrl().trim();1492 if (!url) return;1493 setTesting(true);1494 setTestResult(null);1495 const res = await sendMessage({1496 type: "FIRE_CANARY_PING",1497 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1498 }) as { ok: boolean; error?: string };1499 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1500 setTesting(false);1501 }15021503 return (1504 <div class="peach-canary-section">1505 <div class="peach-field-value-row">1506 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1507 <button1508 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1509 onClick={() => { setEnabled(!enabled()); void save(); }}1510 aria-label="Toggle canary"1511 >1512 <span class="peach-toggle-knob" />1513 </button>1514 </div>1515 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1516 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1517 </p>1518 <Show when={enabled()}>1519 <div class="peach-inline-field-wrap" style="margin-top:8px;">1520 <input1521 class="peach-inline-input"1522 placeholder="https://hooks.zapier.com/..."1523 value={webhookUrl()}1524 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1525 onBlur={() => void save()}1526 />1527 <input1528 class="peach-inline-input"1529 placeholder="label (optional, e.g. AWS decoy)"1530 value={label()}1531 onInput={(e) => setLabel(e.currentTarget.value)}1532 onBlur={() => void save()}1533 />1534 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1535 <button1536 class="peach-btn peach-btn--ghost peach-btn--sm"1537 onClick={() => void testPing()}1538 disabled={testing() || !webhookUrl().trim()}1539 >1540 {testing() ? "sending..." : "test ping"}1541 </button>1542 <Show when={testResult()}>1543 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1544 </Show>1545 </div>1546 <Show when={props.entry.canary?.lastPinged}>1547 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1548 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1549 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1550 </p>1551 </Show>1552 </div>1553 </Show>1554 </div>1555 );1556}