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 createEffect(() => {95 void props.entry?.id;96 setConfirmDelete(false);97 setEditingField(null);98 setEditingTags(false);99 setShowAdvanced(false);100 });101102 const totpState = createMemo(() => {103 void totpTick();104 if (!props.entry?.totp) return null;105 return generateTOTP(props.entry.totp);106 });107108 let prevTotpCode: string | undefined;109 createEffect(() => {110 const state = totpState();111 if (!state) { prevTotpCode = undefined; return; }112 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {113 setTotpHot(true);114 window.setTimeout(() => setTotpHot(false), 3000);115 }116 prevTotpCode = state.code;117 });118119 const totpProgress = createMemo(() => {120 const state = totpState();121 if (!state) return 0;122 return ((state.remaining - 1) / 29) * 100;123 });124125 createEffect(() => {126 const shell = document.getElementById("hd-shell");127 if (!shell) return;128129 const state = totpState();130 if (!state) {131 delete shell.dataset.totpActive;132 shell.style.removeProperty("--peach-totp-progress");133 return;134 }135136 shell.dataset.totpActive = "true";137 shell.style.setProperty(138 "--peach-totp-progress",139 `${Math.max(0, totpProgress())}%`140 );141142 onCleanup(() => {143 delete shell.dataset.totpActive;144 shell.style.removeProperty("--peach-totp-progress");145 });146 });147148 const titleSizeClass = createMemo(() => {149 const length = props.entry?.title.length ?? 0;150 if (length >= 28) return "peach-entry-name--xxl";151 if (length >= 20) return "peach-entry-name--xl";152 if (length >= 14) return "peach-entry-name--lg";153 return "";154 });155 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);156157 158159 function startEdit(field: EditableField, value: string) {160 setEditingField(field);161 setDraft(value);162 setShowGenerator(false);163 setConfirmDelete(false);164 if (field === "url") {165 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);166 }167 }168169 170 171 172 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {173 const c = candidate.trim();174 if (!c) return true;175 if (primary.trim() === c) return true;176 return others.some((u) => u.trim() === c);177 }178179 function addCurrentSite(url: string) {180 if (!url) return;181 if (!draft().trim()) {182 setDraft(url);183 return;184 }185 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;186 setAdditionalUrlsDraft((arr) => [...arr, url]);187 }188189 async function saveField() {190 const field = editingField();191 const entry = props.entry;192 if (!field || !entry) return;193 194 let value = field === "password" ? draft() : draft().trim();195 if (field === "totp" && value.startsWith("otpauth://")) {196 const parsed = parseOtpauthURI(value);197 value = parsed?.secret ?? value;198 }199 if (field === "password" && entry.type !== "note" && !value) {200 return;201 }202 setSaving(true);203 try {204 const patch: Partial<VaultEntry> = { [field]: value };205 if (field === "url") {206 207 patch.additionalUrls = additionalUrlsDraft();208 }209 await sendMessage({210 type: "UPDATE_ENTRY",211 payload: { id: entry.id, patch },212 });213 triggerRing();214 setEditingField(null);215 setShowGenerator(false);216 await props.onFieldSaved();217 } catch {218 219 } finally {220 setSaving(false);221 }222 }223224 function startEditTags() {225 setTagsDraft([...(props.entry?.tags ?? [])]);226 setTagInput("");227 setEditingTags(true);228 }229230 function addTag(value: string) {231 const n = value.toLowerCase().trim();232 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;233 setTagsDraft([...tagsDraft(), n]);234 setTagInput("");235 }236237 async function saveTags() {238 if (tagInput()) addTag(tagInput());239 const entry = props.entry;240 if (!entry) return;241 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;242 setEditingTags(false);243 setSaving(true);244 try {245 await sendMessage({246 type: "UPDATE_ENTRY",247 payload: { id: entry.id, patch: { tags: next } },248 });249 triggerRing();250 await props.onFieldSaved();251 } catch {252 253 } finally {254 setSaving(false);255 }256 }257258 function cancelEdit() {259 setEditingField(null);260 setDraft("");261 setShowGenerator(false);262 }263264 265 266 267 268 async function addOtherIdentity(target: "username" | "email") {269 const entry = props.entry;270 if (!entry) return;271 const current = editingField();272 const value = draft().trim();273 if (current === "username" && target === "email") {274 275 if (value !== (entry.username ?? "")) {276 setSaving(true);277 try {278 await sendMessage({279 type: "UPDATE_ENTRY",280 payload: { id: entry.id, patch: { username: value } },281 });282 await props.onFieldSaved();283 } finally {284 setSaving(false);285 }286 }287 startEdit("email", "");288 return;289 }290 if (current === "username" && target === "username") {291 292 293 if (value.includes("@") && !entry.email) {294 setSaving(true);295 try {296 await sendMessage({297 type: "UPDATE_ENTRY",298 payload: { id: entry.id, patch: { email: value, username: "" } },299 });300 await props.onFieldSaved();301 } finally {302 setSaving(false);303 }304 }305 startEdit("username", "");306 return;307 }308 if (current === "email" && target === "username") {309 310 if (value !== (entry.email ?? "")) {311 setSaving(true);312 try {313 await sendMessage({314 type: "UPDATE_ENTRY",315 payload: { id: entry.id, patch: { email: value } },316 });317 await props.onFieldSaved();318 } finally {319 setSaving(false);320 }321 }322 startEdit("username", "");323 return;324 }325 }326327 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {328 switch (field) {329 case "title":330 return entry.title;331 case "url":332 return entry.url ?? "";333 case "username":334 return entry.username ?? "";335 case "email":336 return entry.email ?? "";337 case "password":338 return entry.password ?? "";339 case "notes":340 return entry.notes ?? "";341 case "totp":342 return entry.totp ?? "";343 }344 }345346 function handleEditorFocusOut(347 field: Exclude<EditableField, null>,348 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }349 ) {350 const nextTarget = event.relatedTarget;351 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;352353 const entry = props.entry;354 if (!entry) return;355356 const currentValue = fieldValue(entry, field);357 const nextValue = field === "password" ? draft() : draft().trim();358 if (nextValue === currentValue) {359 cancelEdit();360 }361 }362363 async function savePassword(password: string) {364 const entry = props.entry;365 if (!entry) return;366 setSaving(true);367 try {368 await sendMessage({369 type: "UPDATE_ENTRY",370 payload: { id: entry.id, patch: { password } },371 });372 triggerRing();373 setShowGenerator(false);374 await props.onFieldSaved();375 } catch {376 377 } finally {378 setSaving(false);379 }380 }381382 383384 async function copyField(text: string, label: string, button?: HTMLButtonElement) {385 const ok = await popupCopy(text, label);386 if (ok) {387 const entry = props.entry;388 if (entry?.canary?.webhookUrl) {389 const trigger =390 label === "password"391 ? "password_copied"392 : label === "username"393 ? "username_copied"394 : undefined;395 if (trigger) {396 void sendMessage({397 type: "FIRE_CANARY_PING",398 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },399 });400 }401 }402 triggerRing();403 triggerPipPulse();404 setCopied(null);405 requestAnimationFrame(() => setCopied(label));406 if (button) {407 button.classList.remove("is-copied", "is-retreat");408 void button.offsetWidth;409 const drain = document.createElement("span");410 drain.className = "peach-copy-drain";411 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";412 drain.appendChild(bar);413 button.appendChild(drain);414 window.setTimeout(() => {415 button.classList.remove("is-copied");416 button.classList.add("is-retreat");417 drain.remove();418 window.setTimeout(() => button.classList.remove("is-retreat"), 160);419 }, 1800);420 }421 window.setTimeout(() => setCopied(null), 1800);422 }423 }424425 426427 createEffect(() => {428 if (!props.entry) return;429 const handler = (e: KeyboardEvent) => {430 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;431 if (inInput) return;432 switch (e.key) {433 case "c": e.preventDefault();434 if (props.entry?.password) void copyField(props.entry.password, "password"); break;435 case "u": e.preventDefault();436 if (props.entry?.username) void copyField(props.entry.username, "username"); break;437 case "Escape": e.preventDefault(); props.onEscape?.(); break;438 }439 };440 window.addEventListener("keydown", handler);441 onCleanup(() => window.removeEventListener("keydown", handler));442 });443444 445446 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {447 requestAnimationFrame(() => { el.focus(); el.select?.(); });448 }449450 function onEditKeyDown(e: KeyboardEvent) {451 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {452 e.preventDefault();453 void saveField();454 }455 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }456 }457458 return (459 <>460 <div461 class="peach-ghost"462 aria-hidden="true"463 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;"464 >465 {props.entry?.title || "empty"}466 </div>467468 <div class="peach-stage" style="position:relative;">469 <Show when={props.entry} fallback={<EmptyVaultState />}>470 {(entry) => (471 <>472 {}473 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>474 <div class="peach-breach-badge">475 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">476 <path d="M6 1L11 10H1L6 1z" />477 <line x1="6" y1="5" x2="6" y2="7.5" />478 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />479 </svg>480 <span style="flex:1">found in a known data breach</span>481 <button482 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"483 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}484 >485 Change password486 </button>487 </div>488 </Show>489490 {}491 <div class="peach-entry-header">492 <div class="peach-entry-header-name">493 <Show494 when={editingField() === "title"}495 fallback={496 <button497 class="peach-entry-name-button"498 title="Click to edit title"499 onClick={() => startEdit("title", entry().title)}500 >501 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>502 <span class="peach-entry-name-text">503 <Show504 when={useAnimatedTitle()}505 fallback={entry().title}506 >507 <For each={entry().title.split("")}>508 {(ch, i) => (509 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>510 {ch === " " ? "\u00A0" : ch}511 </span>512 )}513 </For>514 </Show>515 </span>516 </h1>517 </button>518 }519 >520 <div521 class="peach-inline-title-wrap"522 onFocusOut={(event) => handleEditorFocusOut("title", event)}523 >524 <input525 class="peach-inline-title-input"526 value={draft()}527 onInput={(e) => setDraft(e.currentTarget.value)}528 onKeyDown={onEditKeyDown}529 ref={autoFocus}530 disabled={saving()}531 />532 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />533 </div>534 </Show>535 </div>536537 {}538 <Show when={editingField() !== "title"}>539 <Show540 when={confirmDelete()}541 fallback={542 <button543 class="peach-entry-trash-btn"544 onClick={() => setConfirmDelete(true)}545 aria-label="Move to trash"546 title="Delete entry"547 >548 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">549 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />550 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />551 </svg>552 </button>553 }554 >555 <div class="peach-entry-trash-confirm">556 <span class="peach-entry-trash-confirm-label">trash?</span>557 <button558 class="peach-btn peach-btn--sm peach-btn--danger"559 style="font-size:10px;padding:2px 8px;min-height:22px;"560 onClick={() => { setConfirmDelete(false); props.onDelete(); }}561 >562 yes563 </button>564 <button565 class="peach-btn peach-btn--ghost peach-btn--sm"566 style="font-size:10px;padding:2px 8px;min-height:22px;"567 onClick={() => setConfirmDelete(false)}568 >569 no570 </button>571 </div>572 </Show>573 </Show>574 </div>575576 {}577 <Show when={entry().type !== "note" && entry().type !== "card"}>578 <div class="peach-domain" style="position:relative;z-index:1;">579 <Show580 when={editingField() === "url"}581 fallback={582 <div583 class="peach-domain-row peach-inline-clickable"584 title="Click to edit URL"585 onClick={() => startEdit("url", entry().url ?? "")}586 >587 <Show when={faviconSrc()}>588 <img589 src={faviconSrc()!}590 width="16"591 height="16"592 alt=""593 style="border-radius:3px;margin-right:5px;flex-shrink:0;"594 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}595 />596 </Show>597 <span class="peach-domain-value">598 {displayDomain() || (entry().url ? entry().url : "add a website url")}599 </span>600 <span class="peach-inline-edit-hint">edit</span>601 </div>602 }603 >604 <div605 class="peach-inline-field-wrap peach-inline-field-wrap--url"606 onFocusOut={(event) => handleEditorFocusOut("url", event)}607 >608 <div class="peach-inline-field-row">609 <input610 class="peach-inline-input"611 type="url"612 placeholder="https://"613 value={draft()}614 onInput={(e) => setDraft(e.currentTarget.value)}615 onKeyDown={onEditKeyDown}616 ref={autoFocus}617 disabled={saving()}618 />619 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />620 </div>621 <div class="peach-additional-urls">622 <Show when={additionalUrlsDraft().length > 0}>623 <span class="peach-label peach-additional-urls-label">also matches</span>624 </Show>625 <For each={additionalUrlsDraft()}>626 {(value, i) => (627 <div class="peach-additional-url-row">628 <input629 class="peach-inline-input"630 type="url"631 placeholder="https://"632 value={value}633 onInput={(e) => setAdditionalUrlsDraft((arr) => {634 const next = [...arr];635 next[i()] = e.currentTarget.value;636 return next;637 })}638 disabled={saving()}639 />640 <button641 class="peach-additional-url-remove"642 type="button"643 aria-label="Remove URL"644 onMouseDown={(e) => e.preventDefault()}645 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}646 disabled={saving()}647 >×</button>648 </div>649 )}650 </For>651 <button652 class="peach-add-field-link peach-additional-urls-add"653 type="button"654 onMouseDown={(e) => e.preventDefault()}655 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}656 disabled={saving()}657 >658 + add another url659 </button>660 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>661 <button662 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"663 type="button"664 title={props.currentTabUrl}665 onMouseDown={(e) => e.preventDefault()}666 onClick={() => addCurrentSite(props.currentTabUrl!)}667 disabled={saving()}668 >669 + add current site670 </button>671 </Show>672 </div>673 </div>674 </Show>675 </div>676 </Show>677678 {}679 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>680 <div681 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"682 onClick={startEditTags}683 title="Click to edit tags"684 >685 <For each={entry().tags!}>686 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}687 </For>688 </div>689 </Show>690691 {}692 <Show when={entry().type === "note"}>693 <div class="peach-entry-note-toolbar-row">694 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />695 </div>696 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">697 <NoteEditorInline698 entryKey={entry().id}699 initialContent={entry().notes ?? ""}700 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}701 onBlur={saveNoteInline}702 autoFocus={false}703 />704 </section>705 </Show>706707 {}708 <Show when={entry().type === "card"}>709 <section class="peach-fields" style="position:relative;z-index:1;">710 {}711 <Show when={entry().last4}>712 <div class="peach-field-card" style="padding-bottom:4px;">713 <span class="peach-label">714 <Show when={entry().network && entry().network !== "unknown"}>715 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>716 </Show>717 {entry().cardNickname || `•••• ${entry().last4}`}718 </span>719 </div>720 </Show>721722 {}723 <div class="peach-field-card">724 <span class="peach-label">cardholder name</span>725 <div class="peach-field-value-row">726 <span class="peach-field-value">{entry().cardholderName || "—"}</span>727 <Show when={entry().cardholderName}>728 <button729 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}730 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}731 aria-label="Copy cardholder name"732 >733 <CopyGlyph /><span class="peach-copy-sheen" />734 </button>735 </Show>736 </div>737 </div>738739 {}740 <div class="peach-field-card">741 <span class="peach-label">card number</span>742 <div class="peach-field-value-row">743 <span class={`peach-field-value peach-card-number`}>744 <Show when={showCardNumber()} fallback={745 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>746 }>747 <span>{entry().cardNumber ?? ""}</span>748 </Show>749 </span>750 <div class="peach-field-actions">751 <button752 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"753 onClick={() => setShowCardNumber(!showCardNumber())}754 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}755 >756 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>757 <EyeClosedGlyph />758 </Show>759 </button>760 <Show when={entry().cardNumber}>761 <button762 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}763 onClick={(e) => {764 const cardNum = entry().cardNumber!;765 if (entry().canary?.webhookUrl) {766 void sendMessage({767 type: "FIRE_CANARY_PING",768 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },769 });770 }771 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);772 }}773 aria-label="Copy card number"774 >775 <CopyGlyph /><span class="peach-copy-sheen" />776 </button>777 </Show>778 </div>779 </div>780 </div>781782 {}783 <Show when={entry().expMonth || entry().expYear}>784 <div class="peach-field-card">785 <span class="peach-label">expiry</span>786 <div class="peach-field-value-row">787 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>788 <button789 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}790 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}791 aria-label="Copy expiry"792 >793 <CopyGlyph /><span class="peach-copy-sheen" />794 </button>795 </div>796 </div>797 </Show>798799 {}800 <Show when={entry().cvc}>801 <div class="peach-field-card">802 <span class="peach-label">CVC</span>803 <div class="peach-field-value-row">804 <span class="peach-field-value">805 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>806 <span>{entry().cvc}</span>807 </Show>808 </span>809 <div class="peach-field-actions">810 <button811 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"812 onClick={() => setShowCardCvc(!showCardCvc())}813 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}814 >815 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>816 <EyeClosedGlyph />817 </Show>818 </button>819 <button820 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}821 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}822 aria-label="Copy CVC"823 >824 <CopyGlyph /><span class="peach-copy-sheen" />825 </button>826 </div>827 </div>828 </div>829 </Show>830 </section>831 </Show>832833 {}834 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>835 <section class="peach-fields" style="position:relative;z-index:1;">836837 {838839}840 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>841 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>842 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>843 <Show844 when={editingField() === "username"}845 fallback={846 <div class="peach-field-value-row">847 <span848 class="peach-field-value peach-inline-clickable"849 title="Click to edit"850 onClick={() => startEdit("username", entry().username ?? "")}851 >852 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}853 </span>854 <Show when={entry().username}>855 <button856 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}857 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}858 aria-label="Copy username"859 >860 <CopyGlyph /><span class="peach-copy-sheen" />861 </button>862 </Show>863 </div>864 }865 >866 <div867 class="peach-inline-field-wrap"868 onFocusOut={(event) => handleEditorFocusOut("username", event)}869 >870 <input871 class="peach-inline-input"872 value={draft()}873 onInput={(e) => setDraft(e.currentTarget.value)}874 onKeyDown={onEditKeyDown}875 ref={autoFocus}876 disabled={saving()}877 autocomplete="off"878 />879 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />880 {881882883}884 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>885 <button886 class="peach-add-field-btn"887 type="button"888 onMouseDown={(e) => e.preventDefault()}889 onClick={() => {890 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);891 const hasSeparateEmail = !!entry().email;892 if (isEmailShape && !hasSeparateEmail) {893 void addOtherIdentity("username");894 } else {895 void addOtherIdentity("email");896 }897 }}898 >899 {(() => {900 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);901 const hasSeparateEmail = !!entry().email;902 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";903 })()}904 </button>905 </Show>906 </div>907 </Show>908 </div>909 </Show>910911 {912}913 <Show when={entry().email || editingField() === "email"}>914 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>915 <span class="peach-label">email</span>916 <Show917 when={editingField() === "email"}918 fallback={919 <div class="peach-field-value-row">920 <span921 class="peach-field-value peach-inline-clickable"922 title="Click to edit"923 onClick={() => startEdit("email", entry().email ?? "")}924 >925 {entry().email}926 </span>927 <Show when={entry().email}>928 <button929 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}930 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}931 aria-label="Copy email"932 >933 <CopyGlyph /><span class="peach-copy-sheen" />934 </button>935 </Show>936 </div>937 }938 >939 <div940 class="peach-inline-field-wrap"941 onFocusOut={(event) => handleEditorFocusOut("email", event)}942 >943 <input944 class="peach-inline-input"945 value={draft()}946 onInput={(e) => setDraft(e.currentTarget.value)}947 onKeyDown={onEditKeyDown}948 ref={autoFocus}949 disabled={saving()}950 autocomplete="email"951 type="email"952 />953 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />954 <Show when={!entry().username || entry().username?.includes("@")}>955 <button956 class="peach-add-field-btn"957 type="button"958 onMouseDown={(e) => e.preventDefault()}959 onClick={() => void addOtherIdentity("username")}960 >961 + add username962 </button>963 </Show>964 </div>965 </Show>966 </div>967 </Show>968969 {}970 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>971 <span class="peach-label">password</span>972 <Show973 when={editingField() === "password"}974 fallback={975 <div class="peach-field-value-row">976 <span977 class="peach-password-value"978 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}979 style="cursor:pointer;"980 title="Click to edit password"981 onClick={() => startEdit("password", entry().password ?? "")}982 >983 <Show984 when={showPassword()}985 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}986 >987 <span class="peach-password-text">{entry().password ?? ""}</span>988 </Show>989 </span>990 <div class="peach-field-actions">991 <button992 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"993 onClick={() => setShowPassword(!showPassword())}994 aria-label="Reveal password"995 >996 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>997 <EyeClosedGlyph />998 </Show>999 </button>1000 <button1001 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1002 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1003 aria-label="Copy password"1004 >1005 <CopyGlyph /><span class="peach-copy-sheen" />1006 </button>1007 </div>1008 </div>1009 }1010 >1011 {}1012 <div1013 class="peach-inline-field-wrap"1014 onFocusOut={(event) => handleEditorFocusOut("password", event)}1015 >1016 <div class="peach-password-edit-row">1017 <input1018 class="peach-inline-input"1019 type={showPassword() ? "text" : "password"}1020 placeholder="paste or type a password"1021 value={draft()}1022 onInput={(e) => setDraft(e.currentTarget.value)}1023 onKeyDown={(e) => {1024 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1025 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1026 }}1027 ref={autoFocus}1028 disabled={saving()}1029 autocomplete="new-password"1030 />1031 <button1032 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1033 type="button"1034 onClick={() => setShowPassword(!showPassword())}1035 aria-label={showPassword() ? "Hide" : "Show"}1036 >1037 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1038 <EyeClosedGlyph />1039 </Show>1040 </button>1041 </div>1042 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1043 <button1044 class="peach-generate-toggle-link"1045 type="button"1046 onClick={() => setShowGenerator(!showGenerator())}1047 >1048 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1049 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1050 <path d="M9.5 2.5H11V1" />1051 </svg>1052 {showGenerator() ? "hide generator" : "generate"}1053 </button>1054 <Show when={showGenerator()}>1055 <GeneratorDrawer1056 open={true}1057 compact={true}1058 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1059 />1060 </Show>1061 </div>1062 </Show>1063 </div>10641065 </section>1066 </Show>10671068 {}1069 <Show when={entry().type !== "note" && entry().type !== "card"}>1070 <Show1071 when={entry().notes || editingField() === "notes"}1072 fallback={1073 <button1074 class="peach-add-field-link peach-entry-add-link"1075 onClick={() => startEdit("notes", "")}1076 >1077 + notes1078 </button>1079 }1080 >1081 <div style="position:relative;z-index:1;margin-top:6px;">1082 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1083 <Show1084 when={editingField() === "notes"}1085 fallback={1086 <div1087 class="peach-note-content peach-inline-clickable"1088 style="cursor:text;white-space:pre-wrap;"1089 onClick={() => startEdit("notes", entry().notes ?? "")}1090 >1091 {entry().notes}1092 </div>1093 }1094 >1095 <div1096 class="peach-inline-field-wrap"1097 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1098 >1099 <textarea1100 class="peach-inline-textarea"1101 value={draft()}1102 onInput={(e) => setDraft(e.currentTarget.value)}1103 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1104 ref={(el) => requestAnimationFrame(() => el?.focus())}1105 rows={4}1106 disabled={saving()}1107 />1108 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1109 </div>1110 </Show>1111 </div>1112 </Show>1113 </Show>11141115 {}1116 <Show1117 when={editingTags()}1118 fallback={1119 <Show when={!entry().tags || entry().tags!.length === 0}>1120 <button1121 class="peach-add-field-link peach-entry-add-link"1122 onClick={startEditTags}1123 >1124 + tags1125 </button>1126 </Show>1127 }1128 >1129 <div1130 class="peach-inline-tag-editor"1131 onFocusOut={(e) => {1132 const next = e.relatedTarget;1133 if (next instanceof Node && e.currentTarget.contains(next)) return;1134 void saveTags();1135 }}1136 >1137 <span class="peach-label">tags</span>1138 <div class="peach-tag-input-wrap">1139 <For each={tagsDraft()}>1140 {(tag) => (1141 <span class="peach-tag-chip">1142 {tag}1143 <button1144 class="peach-tag-chip-remove"1145 onMouseDown={(e) => e.preventDefault()}1146 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1147 aria-label={`Remove ${tag}`}1148 >×</button>1149 </span>1150 )}1151 </For>1152 <input1153 class="peach-tag-input"1154 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1155 value={tagInput()}1156 onInput={(e) => setTagInput(e.currentTarget.value)}1157 onKeyDown={(e) => {1158 if (e.key === "Enter" || e.key === ",") {1159 e.preventDefault();1160 addTag(tagInput());1161 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1162 setTagsDraft(tagsDraft().slice(0, -1));1163 } else if (e.key === "Escape") {1164 e.preventDefault();1165 setEditingTags(false);1166 setTagInput("");1167 }1168 }}1169 ref={(el) => requestAnimationFrame(() => el?.focus())}1170 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1171 />1172 </div>1173 </div>1174 </Show>11751176 {}1177 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1178 <Show1179 when={totpState() || editingField() === "totp"}1180 fallback={1181 <button1182 class="peach-add-field-link peach-entry-add-link"1183 onClick={() => startEdit("totp", "")}1184 >1185 + 2FA1186 </button>1187 }1188 >1189 <section class="peach-totp-band">1190 <Show1191 when={editingField() === "totp"}1192 fallback={1193 <Show when={totpState()}>1194 {(state) => (1195 <>1196 <div1197 class="peach-totp-main"1198 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1199 onDblClick={() => startEdit("totp", entry().totp ?? "")}1200 title="Click to copy · double-click to edit"1201 >1202 <span class="peach-label">2-factor</span>1203 <div class="peach-totp-code-row">1204 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1205 <span class="peach-totp-code-text">1206 {state().code.slice(0, 3)} {state().code.slice(3)}1207 </span>1208 </p>1209 </div>1210 </div>1211 <div class="peach-totp-meta">1212 <span class="peach-label">expires</span>1213 <div class="peach-totp-countdown">{state().remaining}s</div>1214 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1215 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1216 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1217 stroke-dasharray="106.8"1218 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1219 />1220 </svg>1221 </div>1222 </>1223 )}1224 </Show>1225 }1226 >1227 <div1228 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1229 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1230 >1231 <span class="peach-label">2-factor</span>1232 <input1233 class="peach-inline-input"1234 value={draft()}1235 onInput={(e) => setDraft(e.currentTarget.value)}1236 onKeyDown={onEditKeyDown}1237 ref={autoFocus}1238 disabled={saving()}1239 placeholder="otpauth://... or base32 secret"1240 autocomplete="off"1241 />1242 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1243 </div>1244 </Show>1245 </section>1246 </Show>1247 </Show>12481249 {}1250 <Show when={entry().type === "login"}>1251 <button1252 class="peach-add-field-link peach-entry-add-link"1253 onClick={() => setShowAdvanced((v) => !v)}1254 aria-expanded={showAdvanced()}1255 >1256 {showAdvanced() ? "− Advanced" : "+ Advanced"}1257 </button>1258 <Show when={showAdvanced()}>1259 <Show when={!isPasskeyOnly(entry())}>1260 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1261 </Show>12621263 <Show when={entry().passkey}>1264 {(passkey) => {1265 const currentBrowser = detectBrowser();1266 const crossBrowser =1267 passkey().browser &&1268 currentBrowser !== "unknown" &&1269 passkey().browser !== currentBrowser;1270 const algorithmLabel =1271 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1272 const formatDate = (ts?: number) =>1273 ts ? new Date(ts).toLocaleDateString() : "Never";1274 return (1275 <section1276 class="peach-fields peach-passkey-section"1277 style="position:relative;z-index:1;margin-top:6px;"1278 >1279 <div class="peach-field-card">1280 <span class="peach-label">passkey · {passkey().rpId}</span>1281 <div class="peach-field-value-row">1282 <span class="peach-field-value">{passkey().userName}</span>1283 <span class="peach-passkey-badge">passkey</span>1284 </div>1285 </div>12861287 <div class="peach-field-card">1288 <span class="peach-label">algorithm</span>1289 <div class="peach-field-value-row">1290 <span class="peach-field-value">{algorithmLabel}</span>1291 </div>1292 </div>12931294 <div class="peach-field-card">1295 <span class="peach-label">activity</span>1296 <div class="peach-field-value-row peach-passkey-activity">1297 <span class="peach-field-value">1298 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1299 </span>1300 <span class="peach-passkey-meta">1301 created {formatDate(passkey().createdAt)}1302 </span>1303 <span class="peach-passkey-meta">1304 last used {formatDate(passkey().lastUsedAt)}1305 </span>1306 </div>1307 </div>13081309 <Show when={crossBrowser}>1310 <div class="peach-info-notice peach-info-notice--warn">1311 {`This passkey was created on ${1312 passkey().browser === "chrome" ? "Chrome" : "Firefox"1313 }. To use it on ${1314 currentBrowser === "chrome" ? "Chrome" : "Firefox"1315 }, re-register it on this device.`}1316 </div>1317 </Show>13181319 <div class="peach-info-notice">1320 {passkey().browser === "firefox"1321 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1322 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1323 </div>13241325 <button1326 class="peach-add-field-link peach-entry-add-link"1327 style="color:rgba(220,38,38,0.7);"1328 onClick={async () => {1329 const id = entry().id;1330 await sendMessage({1331 type: "UPDATE_ENTRY",1332 payload: { id, patch: { passkey: undefined } },1333 });1334 await props.onFieldSaved();1335 }}1336 >1337 − remove passkey1338 </button>1339 </section>1340 );1341 }}1342 </Show>1343 </Show>1344 </Show>13451346 {}1347 <div style="height:4px;" />1348 </>1349 )}1350 </Show>1351 </div>13521353 </>1354 );1355}1356135713581359function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1360 return (1361 <div class="peach-inline-actions">1362 <button1363 class="peach-inline-save-btn"1364 onClick={props.onSave}1365 disabled={props.saving}1366 aria-label="Save"1367 title="Save (Enter)"1368 >1369 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1370 <path d="M1 4.5L4 7.5L10 1.5" />1371 </svg>1372 </button>1373 <button1374 class="peach-inline-cancel-btn"1375 onClick={props.onCancel}1376 disabled={props.saving}1377 aria-label="Cancel"1378 title="Cancel (Esc)"1379 >1380 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1381 <path d="M1 1L8 8M8 1L1 8" />1382 </svg>1383 </button>1384 </div>1385 );1386}13871388function EmptyVaultState() {1389 return (1390 <>1391 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1392 <div class="peach-entry-name">1393 <span class="peach-entry-name-text">1394 <span class="peach-entry-name-char" style="--char-index:0">e</span>1395 <span class="peach-entry-name-char" style="--char-index:1">m</span>1396 <span class="peach-entry-name-char" style="--char-index:2">p</span>1397 <span class="peach-entry-name-char" style="--char-index:3">t</span>1398 <span class="peach-entry-name-char" style="--char-index:4">y</span>1399 </span>1400 </div>1401 </div>1402 <div class="peach-empty-copy">1403 <span class="peach-label">vault</span>1404 <p>Your vault is ready for its first entry.</p>1405 </div>1406 </>1407 );1408}14091410function CopyGlyph() {1411 return (1412 <span class="peach-copy-icon">1413 <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">1414 <rect x="4" y="4" width="8" height="9" rx="1.5" />1415 <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" />1416 </svg>1417 </span>1418 );1419}14201421function EyeClosedGlyph() {1422 return (1423 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1424 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1425 <path d="M3.5 5.5L2.5 7.5" />1426 <path d="M7 6.5V8.5" />1427 <path d="M10.5 5.5L11.5 7.5" />1428 </svg>1429 );1430}14311432function EyeOpenGlyph() {1433 return (1434 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1435 <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" />1436 <circle cx="7" cy="5" r="1.75" />1437 </svg>1438 );1439}14401441function ChevronGlyph(props: { open: boolean }) {1442 return (1443 <svg1444 width="8"1445 height="8"1446 viewBox="0 0 8 8"1447 fill="none"1448 stroke="currentColor"1449 stroke-width="1.3"1450 stroke-linecap="round"1451 stroke-linejoin="round"1452 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1453 >1454 <path d="M2 1L6 4L2 7" />1455 </svg>1456 );1457}14581459function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1460 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1461 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1462 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1463 const [testResult, setTestResult] = createSignal<string | null>(null);1464 const [testing, setTesting] = createSignal(false);14651466 async function save() {1467 const url = webhookUrl().trim();1468 const nextLabel = label().trim() || undefined;1469 const canary = enabled() && url1470 ? {1471 webhookUrl: url,1472 label: nextLabel,1473 lastPinged: props.entry.canary?.lastPinged,1474 pingCount: props.entry.canary?.pingCount,1475 }1476 : undefined;1477 await sendMessage({1478 type: "UPDATE_ENTRY",1479 payload: { id: props.entry.id, patch: { canary } },1480 });1481 await props.onSaved();1482 }14831484 async function testPing() {1485 const url = webhookUrl().trim();1486 if (!url) return;1487 setTesting(true);1488 setTestResult(null);1489 const res = await sendMessage({1490 type: "FIRE_CANARY_PING",1491 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1492 }) as { ok: boolean; error?: string };1493 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1494 setTesting(false);1495 }14961497 return (1498 <div class="peach-canary-section">1499 <div class="peach-field-value-row">1500 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1501 <button1502 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1503 onClick={() => { setEnabled(!enabled()); void save(); }}1504 aria-label="Toggle canary"1505 >1506 <span class="peach-toggle-knob" />1507 </button>1508 </div>1509 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1510 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1511 </p>1512 <Show when={enabled()}>1513 <div class="peach-inline-field-wrap" style="margin-top:8px;">1514 <input1515 class="peach-inline-input"1516 placeholder="https://hooks.zapier.com/..."1517 value={webhookUrl()}1518 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1519 onBlur={() => void save()}1520 />1521 <input1522 class="peach-inline-input"1523 placeholder="label (optional, e.g. AWS decoy)"1524 value={label()}1525 onInput={(e) => setLabel(e.currentTarget.value)}1526 onBlur={() => void save()}1527 />1528 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1529 <button1530 class="peach-btn peach-btn--ghost peach-btn--sm"1531 onClick={() => void testPing()}1532 disabled={testing() || !webhookUrl().trim()}1533 >1534 {testing() ? "sending..." : "test ping"}1535 </button>1536 <Show when={testResult()}>1537 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1538 </Show>1539 </div>1540 <Show when={props.entry.canary?.lastPinged}>1541 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1542 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1543 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1544 </p>1545 </Show>1546 </div>1547 </Show>1548 </div>1549 );1550}