1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { GeneratorDrawer } from "../components/GeneratorDrawer";89type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1011interface EntryProps {12 entry: VaultEntry | null;13 onDelete: () => void;14 15 onFieldSaved: () => void | Promise<void>;16 onEscape?: () => void;17}1819export function EntryView(props: EntryProps) {20 const [editingField, setEditingField] = createSignal<EditableField>(null);21 const [draft, setDraft] = createSignal("");22 const [saving, setSaving] = createSignal(false);23 const [showPassword, setShowPassword] = createSignal(false);24 const [showGenerator, setShowGenerator] = createSignal(false);25 const [copied, setCopied] = createSignal<string | null>(null);26 const [totpTick, setTotpTick] = createSignal(0);27 const [confirmDelete, setConfirmDelete] = createSignal(false);28 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);2930 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);31 onCleanup(() => window.clearInterval(interval));3233 const displayDomain = createMemo(() =>34 props.entry?.url ? getDisplayDomain(props.entry.url) : ""35 );3637 createEffect(() => {38 const domain = displayDomain();39 setFaviconSrc(domain ? faviconUrl(domain) : null);40 });4142 const totpState = createMemo(() => {43 void totpTick();44 if (!props.entry?.totp) return null;45 return generateTOTP(props.entry.totp);46 });4748 let prevTotpCode: string | undefined;49 createEffect(() => {50 const state = totpState();51 if (!state) { prevTotpCode = undefined; return; }52 if (prevTotpCode !== undefined && state.code !== prevTotpCode) triggerRing();53 prevTotpCode = state.code;54 });5556 const totpProgress = createMemo(() => {57 const state = totpState();58 if (!state) return 0;59 return ((state.remaining - 1) / 29) * 100;60 });6162 6364 function startEdit(field: EditableField, value: string) {65 setEditingField(field);66 setDraft(value);67 setShowGenerator(false);68 setConfirmDelete(false);69 }7071 async function saveField() {72 const field = editingField();73 const entry = props.entry;74 if (!field || !entry) return;75 76 const value = field === "password" ? draft() : draft().trim();77 if (field === "password" && entry.type !== "note" && !value) {78 return;79 }80 setSaving(true);81 try {82 await sendMessage({83 type: "UPDATE_ENTRY",84 payload: { id: entry.id, patch: { [field]: value } },85 });86 triggerRing();87 setEditingField(null);88 setShowGenerator(false);89 await props.onFieldSaved();90 } catch {91 92 } finally {93 setSaving(false);94 }95 }9697 function cancelEdit() {98 setEditingField(null);99 setDraft("");100 setShowGenerator(false);101 }102103 async function savePassword(password: string) {104 const entry = props.entry;105 if (!entry) return;106 setSaving(true);107 try {108 await sendMessage({109 type: "UPDATE_ENTRY",110 payload: { id: entry.id, patch: { password } },111 });112 triggerRing();113 setShowGenerator(false);114 await props.onFieldSaved();115 } catch {116 117 } finally {118 setSaving(false);119 }120 }121122 123124 async function copyField(text: string, label: string, button?: HTMLButtonElement) {125 const response = await sendMessage({ type: "COPY", payload: { text, label } });126 if ((response as { ok: boolean }).ok) {127 const entry = props.entry;128 if (entry?.canary?.webhookUrl) {129 const trigger =130 label === "password"131 ? "password_copied"132 : label === "username"133 ? "username_copied"134 : undefined;135 if (trigger) {136 void sendMessage({137 type: "FIRE_CANARY_PING",138 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },139 });140 }141 }142 triggerRing();143 triggerPipPulse();144 setCopied(null);145 requestAnimationFrame(() => setCopied(label));146 if (button) {147 button.classList.remove("is-copied", "is-retreat");148 void button.offsetWidth;149 const drain = document.createElement("span");150 drain.className = "peach-copy-drain";151 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";152 drain.appendChild(bar);153 button.appendChild(drain);154 window.setTimeout(() => {155 button.classList.remove("is-copied");156 button.classList.add("is-retreat");157 drain.remove();158 window.setTimeout(() => button.classList.remove("is-retreat"), 160);159 }, 1800);160 }161 window.setTimeout(() => setCopied(null), 1800);162 }163 }164165 166167 createEffect(() => {168 if (!props.entry) return;169 const handler = (e: KeyboardEvent) => {170 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;171 if (inInput) return;172 switch (e.key) {173 case "c": e.preventDefault();174 if (props.entry?.password) void copyField(props.entry.password, "password"); break;175 case "u": e.preventDefault();176 if (props.entry?.username) void copyField(props.entry.username, "username"); break;177 case "Escape": e.preventDefault(); props.onEscape?.(); break;178 }179 };180 window.addEventListener("keydown", handler);181 onCleanup(() => window.removeEventListener("keydown", handler));182 });183184 185186 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {187 requestAnimationFrame(() => { el.focus(); el.select?.(); });188 }189190 function onEditKeyDown(e: KeyboardEvent) {191 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {192 e.preventDefault();193 void saveField();194 }195 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }196 }197198 return (199 <>200 <div201 class="peach-ghost"202 aria-hidden="true"203 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;"204 >205 {props.entry?.title || "empty"}206 </div>207208 <div class="peach-stage" style="position:relative;">209 <Show when={props.entry} fallback={<EmptyVaultState />}>210 {(entry) => (211 <>212 {}213 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>214 <div class="peach-breach-badge">215 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">216 <path d="M6 1L11 10H1L6 1z" />217 <line x1="6" y1="5" x2="6" y2="7.5" />218 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />219 </svg>220 <span style="flex:1">found in a known data breach</span>221 <button222 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"223 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}224 >225 Change password226 </button>227 </div>228 </Show>229230 {}231 <div class="peach-entry-header">232 <div class="peach-entry-header-name">233 <Show234 when={editingField() === "title"}235 fallback={236 <button237 class="peach-entry-name-button"238 title="Click to edit title"239 onClick={() => startEdit("title", entry().title)}240 >241 <h1 class="peach-entry-name" aria-label={entry().title}>242 <span class="peach-entry-name-text">243 <For each={entry().title.split("")}>244 {(ch, i) => (245 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>246 {ch === " " ? "\u00A0" : ch}247 </span>248 )}249 </For>250 </span>251 </h1>252 </button>253 }254 >255 <div class="peach-inline-title-wrap">256 <input257 class="peach-inline-title-input"258 value={draft()}259 onInput={(e) => setDraft(e.currentTarget.value)}260 onKeyDown={onEditKeyDown}261 ref={autoFocus}262 disabled={saving()}263 />264 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />265 </div>266 </Show>267 </div>268269 {}270 <Show when={editingField() !== "title"}>271 <Show272 when={confirmDelete()}273 fallback={274 <button275 class="peach-entry-trash-btn"276 onClick={() => setConfirmDelete(true)}277 aria-label="Move to trash"278 title="Delete entry"279 >280 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">281 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />282 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />283 </svg>284 </button>285 }286 >287 <div class="peach-entry-trash-confirm">288 <span class="peach-entry-trash-confirm-label">trash?</span>289 <button290 class="peach-btn peach-btn--sm peach-btn--danger"291 style="font-size:10px;padding:2px 8px;min-height:22px;"292 onClick={() => { setConfirmDelete(false); props.onDelete(); }}293 >294 yes295 </button>296 <button297 class="peach-btn peach-btn--ghost peach-btn--sm"298 style="font-size:10px;padding:2px 8px;min-height:22px;"299 onClick={() => setConfirmDelete(false)}300 >301 no302 </button>303 </div>304 </Show>305 </Show>306 </div>307308 {}309 <Show when={entry().type !== "note"}>310 <div class="peach-domain" style="position:relative;z-index:1;">311 <Show312 when={editingField() === "url"}313 fallback={314 <div315 class="peach-domain-row peach-inline-clickable"316 title="Click to edit URL"317 onClick={() => startEdit("url", entry().url ?? "")}318 >319 <Show when={faviconSrc()}>320 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />321 </Show>322 <span class="peach-domain-value">323 {displayDomain() || (entry().url ? entry().url : "add a website url")}324 </span>325 <span class="peach-inline-edit-hint">edit</span>326 </div>327 }328 >329 <div class="peach-inline-field-wrap">330 <input331 class="peach-inline-input"332 type="url"333 placeholder="https://"334 value={draft()}335 onInput={(e) => setDraft(e.currentTarget.value)}336 onKeyDown={onEditKeyDown}337 ref={autoFocus}338 disabled={saving()}339 />340 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />341 </div>342 </Show>343 </div>344 </Show>345346 {}347 <Show when={entry().tags && entry().tags!.length > 0}>348 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">349 <For each={entry().tags!}>350 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}351 </For>352 </div>353 </Show>354355 {}356 <Show when={entry().type === "note"}>357 <section class="peach-fields" style="position:relative;z-index:1;">358 <Show359 when={editingField() === "notes"}360 fallback={361 <div362 class="peach-note-content peach-inline-clickable"363 style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"364 title="Click to edit"365 onClick={() => startEdit("notes", entry().notes ?? "")}366 >367 {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}368 </div>369 }370 >371 <div class="peach-inline-field-wrap">372 <textarea373 class="peach-inline-textarea"374 value={draft()}375 onInput={(e) => setDraft(e.currentTarget.value)}376 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}377 ref={(el) => requestAnimationFrame(() => el?.focus())}378 rows={6}379 disabled={saving()}380 />381 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />382 </div>383 </Show>384 </section>385 </Show>386387 {}388 <Show when={entry().type !== "note"}>389 <section class="peach-fields" style="position:relative;z-index:1;">390391 {}392 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>393 <span class="peach-label">username</span>394 <Show395 when={editingField() === "username"}396 fallback={397 <div class="peach-field-value-row">398 <span399 class="peach-field-value peach-inline-clickable"400 title="Click to edit"401 onClick={() => startEdit("username", entry().username ?? "")}402 >403 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}404 </span>405 <Show when={entry().username}>406 <button407 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}408 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}409 aria-label="Copy username"410 >411 <CopyGlyph /><span class="peach-copy-sheen" />412 </button>413 </Show>414 </div>415 }416 >417 <div class="peach-inline-field-wrap">418 <input419 class="peach-inline-input"420 value={draft()}421 onInput={(e) => setDraft(e.currentTarget.value)}422 onKeyDown={onEditKeyDown}423 ref={autoFocus}424 disabled={saving()}425 autocomplete="off"426 />427 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />428 </div>429 </Show>430 </div>431432 {}433 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>434 <span class="peach-label">password</span>435 <Show436 when={editingField() === "password"}437 fallback={438 <div class="peach-field-value-row">439 <span440 class="peach-password-value"441 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}442 style="cursor:pointer;"443 title="Click to edit password"444 onClick={() => startEdit("password", entry().password ?? "")}445 >446 <Show447 when={showPassword()}448 fallback={<span class="peach-password-mask">...............</span>}449 >450 <span class="peach-password-text">{entry().password ?? ""}</span>451 </Show>452 </span>453 <div class="peach-field-actions">454 <button455 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"456 onClick={() => setShowPassword(!showPassword())}457 aria-label="Reveal password"458 >459 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>460 <EyeClosedGlyph />461 </Show>462 </button>463 <button464 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}465 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}466 aria-label="Copy password"467 >468 <CopyGlyph /><span class="peach-copy-sheen" />469 </button>470 </div>471 </div>472 }473 >474 {}475 <div class="peach-inline-field-wrap">476 <div class="peach-password-edit-row">477 <input478 class="peach-inline-input"479 type={showPassword() ? "text" : "password"}480 placeholder="paste or type a password"481 value={draft()}482 onInput={(e) => setDraft(e.currentTarget.value)}483 onKeyDown={(e) => {484 if (e.key === "Enter") { e.preventDefault(); void saveField(); }485 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }486 }}487 ref={autoFocus}488 disabled={saving()}489 autocomplete="new-password"490 />491 <button492 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"493 type="button"494 onClick={() => setShowPassword(!showPassword())}495 aria-label={showPassword() ? "Hide" : "Show"}496 >497 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>498 <EyeClosedGlyph />499 </Show>500 </button>501 </div>502 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />503 <button504 class="peach-generate-toggle-link"505 type="button"506 onClick={() => setShowGenerator(!showGenerator())}507 >508 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">509 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />510 <path d="M9.5 2.5H11V1" />511 </svg>512 {showGenerator() ? "hide generator" : "generate"}513 </button>514 <Show when={showGenerator()}>515 <GeneratorDrawer516 open={true}517 compact={true}518 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}519 />520 </Show>521 </div>522 </Show>523 </div>524525 </section>526 </Show>527528 {}529 <Show when={entry().type !== "note"}>530 <Show531 when={entry().notes || editingField() === "notes"}532 fallback={533 <button534 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"535 onClick={() => startEdit("notes", "")}536 >537 + add notes538 </button>539 }540 >541 <div style="position:relative;z-index:1;margin-top:6px;">542 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>543 <Show544 when={editingField() === "notes"}545 fallback={546 <div547 class="peach-note-content peach-inline-clickable"548 style="cursor:text;white-space:pre-wrap;"549 onClick={() => startEdit("notes", entry().notes ?? "")}550 >551 {entry().notes}552 </div>553 }554 >555 <div class="peach-inline-field-wrap">556 <textarea557 class="peach-inline-textarea"558 value={draft()}559 onInput={(e) => setDraft(e.currentTarget.value)}560 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}561 ref={(el) => requestAnimationFrame(() => el?.focus())}562 rows={4}563 disabled={saving()}564 />565 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />566 </div>567 </Show>568 </div>569 </Show>570 </Show>571572 {}573 <Show when={totpState()}>574 {(state) => (575 <section class="peach-totp-band">576 <div class="peach-totp-main">577 <span class="peach-label">2-factor</span>578 <div class="peach-totp-code-row">579 <p class="peach-totp-code">580 <span class="peach-totp-code-text">581 {state().code.slice(0, 3)} {state().code.slice(3)}582 </span>583 </p>584 <button585 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}586 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}587 aria-label="Copy TOTP code"588 >589 <CopyGlyph /><span class="peach-copy-sheen" />590 </button>591 </div>592 </div>593 <div class="peach-totp-meta">594 <span class="peach-label">expires</span>595 <div class="peach-totp-countdown">{state().remaining}s</div>596 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">597 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />598 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"599 stroke-dasharray="106.8"600 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}601 />602 </svg>603 </div>604 <div class="peach-totp-progress">605 <div class="peach-totp-progress-bar" style={`width:${Math.max(0, totpProgress())}%`} />606 </div>607 </section>608 )}609 </Show>610611 {}612 <div style="height:4px;" />613 </>614 )}615 </Show>616 </div>617 </>618 );619}620621622623function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {624 return (625 <div class="peach-inline-actions">626 <button627 class="peach-inline-save-btn"628 onClick={props.onSave}629 disabled={props.saving}630 aria-label="Save"631 title="Save (Enter)"632 >633 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">634 <path d="M1 4.5L4 7.5L10 1.5" />635 </svg>636 </button>637 <button638 class="peach-inline-cancel-btn"639 onClick={props.onCancel}640 disabled={props.saving}641 aria-label="Cancel"642 title="Cancel (Esc)"643 >644 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">645 <path d="M1 1L8 8M8 1L1 8" />646 </svg>647 </button>648 </div>649 );650}651652function EmptyVaultState() {653 return (654 <>655 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">656 <div class="peach-entry-name">657 <span class="peach-entry-name-text">658 <span class="peach-entry-name-char" style="--char-index:0">e</span>659 <span class="peach-entry-name-char" style="--char-index:1">m</span>660 <span class="peach-entry-name-char" style="--char-index:2">p</span>661 <span class="peach-entry-name-char" style="--char-index:3">t</span>662 <span class="peach-entry-name-char" style="--char-index:4">y</span>663 </span>664 </div>665 </div>666 <div class="peach-empty-copy">667 <span class="peach-label">vault</span>668 <p>Your vault is ready for its first entry.</p>669 </div>670 </>671 );672}673674function CopyGlyph() {675 return (676 <span class="peach-copy-icon">677 <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">678 <rect x="4" y="4" width="8" height="9" rx="1.5" />679 <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" />680 </svg>681 </span>682 );683}684685function EyeClosedGlyph() {686 return (687 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">688 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />689 <path d="M3.5 5.5L2.5 7.5" />690 <path d="M7 6.5V8.5" />691 <path d="M10.5 5.5L11.5 7.5" />692 </svg>693 );694}695696function EyeOpenGlyph() {697 return (698 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">699 <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" />700 <circle cx="7" cy="5" r="1.75" />701 </svg>702 );703}704705function ChevronGlyph(props: { open: boolean }) {706 return (707 <svg708 width="8"709 height="8"710 viewBox="0 0 8 8"711 fill="none"712 stroke="currentColor"713 stroke-width="1.3"714 stroke-linecap="round"715 stroke-linejoin="round"716 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}717 >718 <path d="M2 1L6 4L2 7" />719 </svg>720 );721}722723function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {724 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));725 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");726 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");727 const [testResult, setTestResult] = createSignal<string | null>(null);728 const [testing, setTesting] = createSignal(false);729730 async function save() {731 const url = webhookUrl().trim();732 const nextLabel = label().trim() || undefined;733 const canary = enabled() && url734 ? {735 webhookUrl: url,736 label: nextLabel,737 lastPinged: props.entry.canary?.lastPinged,738 pingCount: props.entry.canary?.pingCount,739 }740 : undefined;741 await sendMessage({742 type: "UPDATE_ENTRY",743 payload: { id: props.entry.id, patch: { canary } },744 });745 await props.onSaved();746 }747748 async function testPing() {749 const url = webhookUrl().trim();750 if (!url) return;751 setTesting(true);752 setTestResult(null);753 const res = await sendMessage({754 type: "FIRE_CANARY_PING",755 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },756 }) as { ok: boolean; error?: string };757 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);758 setTesting(false);759 }760761 return (762 <div class="peach-canary-section">763 <div class="peach-field-value-row">764 <span class="peach-field-value" style="font-size:12px;">canary alert</span>765 <button766 class={`peach-toggle ${enabled() ? "is-on" : ""}`}767 onClick={() => { setEnabled(!enabled()); void save(); }}768 aria-label="Toggle canary"769 >770 <span class="peach-toggle-knob" />771 </button>772 </div>773 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">774 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.775 </p>776 <Show when={enabled()}>777 <div class="peach-inline-field-wrap" style="margin-top:8px;">778 <input779 class="peach-inline-input"780 placeholder="https://hooks.zapier.com/..."781 value={webhookUrl()}782 onInput={(e) => setWebhookUrl(e.currentTarget.value)}783 onBlur={() => void save()}784 />785 <input786 class="peach-inline-input"787 placeholder="label (optional, e.g. AWS decoy)"788 value={label()}789 onInput={(e) => setLabel(e.currentTarget.value)}790 onBlur={() => void save()}791 />792 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">793 <button794 class="peach-btn peach-btn--ghost peach-btn--sm"795 onClick={() => void testPing()}796 disabled={testing() || !webhookUrl().trim()}797 >798 {testing() ? "sending..." : "test ping"}799 </button>800 <Show when={testResult()}>801 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>802 </Show>803 </div>804 <Show when={props.entry.canary?.lastPinged}>805 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">806 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}807 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts808 </p>809 </Show>810 </div>811 </Show>812 </div>813 );814}