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 createEffect(() => {63 const shell = document.getElementById("hd-shell");64 if (!shell) return;6566 const state = totpState();67 if (!state) {68 delete shell.dataset.totpActive;69 shell.style.removeProperty("--peach-totp-progress");70 return;71 }7273 shell.dataset.totpActive = "true";74 shell.style.setProperty(75 "--peach-totp-progress",76 `${Math.max(0, totpProgress())}%`77 );7879 onCleanup(() => {80 delete shell.dataset.totpActive;81 shell.style.removeProperty("--peach-totp-progress");82 });83 });8485 const titleSizeClass = createMemo(() => {86 const length = props.entry?.title.length ?? 0;87 if (length >= 28) return "peach-entry-name--xxl";88 if (length >= 20) return "peach-entry-name--xl";89 if (length >= 14) return "peach-entry-name--lg";90 return "";91 });92 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);9394 9596 function startEdit(field: EditableField, value: string) {97 setEditingField(field);98 setDraft(value);99 setShowGenerator(false);100 setConfirmDelete(false);101 }102103 async function saveField() {104 const field = editingField();105 const entry = props.entry;106 if (!field || !entry) return;107 108 const value = field === "password" ? draft() : draft().trim();109 if (field === "password" && entry.type !== "note" && !value) {110 return;111 }112 setSaving(true);113 try {114 await sendMessage({115 type: "UPDATE_ENTRY",116 payload: { id: entry.id, patch: { [field]: value } },117 });118 triggerRing();119 setEditingField(null);120 setShowGenerator(false);121 await props.onFieldSaved();122 } catch {123 124 } finally {125 setSaving(false);126 }127 }128129 function cancelEdit() {130 setEditingField(null);131 setDraft("");132 setShowGenerator(false);133 }134135 async function savePassword(password: string) {136 const entry = props.entry;137 if (!entry) return;138 setSaving(true);139 try {140 await sendMessage({141 type: "UPDATE_ENTRY",142 payload: { id: entry.id, patch: { password } },143 });144 triggerRing();145 setShowGenerator(false);146 await props.onFieldSaved();147 } catch {148 149 } finally {150 setSaving(false);151 }152 }153154 155156 async function copyField(text: string, label: string, button?: HTMLButtonElement) {157 const response = await sendMessage({ type: "COPY", payload: { text, label } });158 if ((response as { ok: boolean }).ok) {159 const entry = props.entry;160 if (entry?.canary?.webhookUrl) {161 const trigger =162 label === "password"163 ? "password_copied"164 : label === "username"165 ? "username_copied"166 : undefined;167 if (trigger) {168 void sendMessage({169 type: "FIRE_CANARY_PING",170 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },171 });172 }173 }174 triggerRing();175 triggerPipPulse();176 setCopied(null);177 requestAnimationFrame(() => setCopied(label));178 if (button) {179 button.classList.remove("is-copied", "is-retreat");180 void button.offsetWidth;181 const drain = document.createElement("span");182 drain.className = "peach-copy-drain";183 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";184 drain.appendChild(bar);185 button.appendChild(drain);186 window.setTimeout(() => {187 button.classList.remove("is-copied");188 button.classList.add("is-retreat");189 drain.remove();190 window.setTimeout(() => button.classList.remove("is-retreat"), 160);191 }, 1800);192 }193 window.setTimeout(() => setCopied(null), 1800);194 }195 }196197 198199 createEffect(() => {200 if (!props.entry) return;201 const handler = (e: KeyboardEvent) => {202 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;203 if (inInput) return;204 switch (e.key) {205 case "c": e.preventDefault();206 if (props.entry?.password) void copyField(props.entry.password, "password"); break;207 case "u": e.preventDefault();208 if (props.entry?.username) void copyField(props.entry.username, "username"); break;209 case "Escape": e.preventDefault(); props.onEscape?.(); break;210 }211 };212 window.addEventListener("keydown", handler);213 onCleanup(() => window.removeEventListener("keydown", handler));214 });215216 217218 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {219 requestAnimationFrame(() => { el.focus(); el.select?.(); });220 }221222 function onEditKeyDown(e: KeyboardEvent) {223 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {224 e.preventDefault();225 void saveField();226 }227 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }228 }229230 return (231 <>232 <div233 class="peach-ghost"234 aria-hidden="true"235 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;"236 >237 {props.entry?.title || "empty"}238 </div>239240 <div class="peach-stage" style="position:relative;">241 <Show when={props.entry} fallback={<EmptyVaultState />}>242 {(entry) => (243 <>244 {}245 <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>246 <div class="peach-breach-badge">247 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">248 <path d="M6 1L11 10H1L6 1z" />249 <line x1="6" y1="5" x2="6" y2="7.5" />250 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />251 </svg>252 <span style="flex:1">found in a known data breach</span>253 <button254 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"255 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}256 >257 Change password258 </button>259 </div>260 </Show>261262 {}263 <div class="peach-entry-header">264 <div class="peach-entry-header-name">265 <Show266 when={editingField() === "title"}267 fallback={268 <button269 class="peach-entry-name-button"270 title="Click to edit title"271 onClick={() => startEdit("title", entry().title)}272 >273 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>274 <span class="peach-entry-name-text">275 <Show276 when={useAnimatedTitle()}277 fallback={entry().title}278 >279 <For each={entry().title.split("")}>280 {(ch, i) => (281 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>282 {ch === " " ? "\u00A0" : ch}283 </span>284 )}285 </For>286 </Show>287 </span>288 </h1>289 </button>290 }291 >292 <div class="peach-inline-title-wrap">293 <input294 class="peach-inline-title-input"295 value={draft()}296 onInput={(e) => setDraft(e.currentTarget.value)}297 onKeyDown={onEditKeyDown}298 ref={autoFocus}299 disabled={saving()}300 />301 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />302 </div>303 </Show>304 </div>305306 {}307 <Show when={editingField() !== "title"}>308 <Show309 when={confirmDelete()}310 fallback={311 <button312 class="peach-entry-trash-btn"313 onClick={() => setConfirmDelete(true)}314 aria-label="Move to trash"315 title="Delete entry"316 >317 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">318 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />319 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />320 </svg>321 </button>322 }323 >324 <div class="peach-entry-trash-confirm">325 <span class="peach-entry-trash-confirm-label">trash?</span>326 <button327 class="peach-btn peach-btn--sm peach-btn--danger"328 style="font-size:10px;padding:2px 8px;min-height:22px;"329 onClick={() => { setConfirmDelete(false); props.onDelete(); }}330 >331 yes332 </button>333 <button334 class="peach-btn peach-btn--ghost peach-btn--sm"335 style="font-size:10px;padding:2px 8px;min-height:22px;"336 onClick={() => setConfirmDelete(false)}337 >338 no339 </button>340 </div>341 </Show>342 </Show>343 </div>344345 {}346 <Show when={entry().type !== "note"}>347 <div class="peach-domain" style="position:relative;z-index:1;">348 <Show349 when={editingField() === "url"}350 fallback={351 <div352 class="peach-domain-row peach-inline-clickable"353 title="Click to edit URL"354 onClick={() => startEdit("url", entry().url ?? "")}355 >356 <Show when={faviconSrc()}>357 <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />358 </Show>359 <span class="peach-domain-value">360 {displayDomain() || (entry().url ? entry().url : "add a website url")}361 </span>362 <span class="peach-inline-edit-hint">edit</span>363 </div>364 }365 >366 <div class="peach-inline-field-wrap">367 <input368 class="peach-inline-input"369 type="url"370 placeholder="https://"371 value={draft()}372 onInput={(e) => setDraft(e.currentTarget.value)}373 onKeyDown={onEditKeyDown}374 ref={autoFocus}375 disabled={saving()}376 />377 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />378 </div>379 </Show>380 </div>381 </Show>382383 {}384 <Show when={entry().tags && entry().tags!.length > 0}>385 <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">386 <For each={entry().tags!}>387 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}388 </For>389 </div>390 </Show>391392 {}393 <Show when={entry().type === "note"}>394 <section class="peach-fields" style="position:relative;z-index:1;">395 <Show396 when={editingField() === "notes"}397 fallback={398 <div399 class="peach-note-content peach-inline-clickable"400 style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"401 title="Click to edit"402 onClick={() => startEdit("notes", entry().notes ?? "")}403 >404 {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}405 </div>406 }407 >408 <div class="peach-inline-field-wrap">409 <textarea410 class="peach-inline-textarea"411 value={draft()}412 onInput={(e) => setDraft(e.currentTarget.value)}413 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}414 ref={(el) => requestAnimationFrame(() => el?.focus())}415 rows={6}416 disabled={saving()}417 />418 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />419 </div>420 </Show>421 </section>422 </Show>423424 {}425 <Show when={entry().type !== "note"}>426 <section class="peach-fields" style="position:relative;z-index:1;">427428 {}429 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>430 <span class="peach-label">username</span>431 <Show432 when={editingField() === "username"}433 fallback={434 <div class="peach-field-value-row">435 <span436 class="peach-field-value peach-inline-clickable"437 title="Click to edit"438 onClick={() => startEdit("username", entry().username ?? "")}439 >440 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}441 </span>442 <Show when={entry().username}>443 <button444 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}445 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}446 aria-label="Copy username"447 >448 <CopyGlyph /><span class="peach-copy-sheen" />449 </button>450 </Show>451 </div>452 }453 >454 <div class="peach-inline-field-wrap">455 <input456 class="peach-inline-input"457 value={draft()}458 onInput={(e) => setDraft(e.currentTarget.value)}459 onKeyDown={onEditKeyDown}460 ref={autoFocus}461 disabled={saving()}462 autocomplete="off"463 />464 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />465 </div>466 </Show>467 </div>468469 {}470 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>471 <span class="peach-label">password</span>472 <Show473 when={editingField() === "password"}474 fallback={475 <div class="peach-field-value-row">476 <span477 class="peach-password-value"478 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}479 style="cursor:pointer;"480 title="Click to edit password"481 onClick={() => startEdit("password", entry().password ?? "")}482 >483 <Show484 when={showPassword()}485 fallback={<span class="peach-password-mask">...............</span>}486 >487 <span class="peach-password-text">{entry().password ?? ""}</span>488 </Show>489 </span>490 <div class="peach-field-actions">491 <button492 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"493 onClick={() => setShowPassword(!showPassword())}494 aria-label="Reveal password"495 >496 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>497 <EyeClosedGlyph />498 </Show>499 </button>500 <button501 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}502 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}503 aria-label="Copy password"504 >505 <CopyGlyph /><span class="peach-copy-sheen" />506 </button>507 </div>508 </div>509 }510 >511 {}512 <div class="peach-inline-field-wrap">513 <div class="peach-password-edit-row">514 <input515 class="peach-inline-input"516 type={showPassword() ? "text" : "password"}517 placeholder="paste or type a password"518 value={draft()}519 onInput={(e) => setDraft(e.currentTarget.value)}520 onKeyDown={(e) => {521 if (e.key === "Enter") { e.preventDefault(); void saveField(); }522 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }523 }}524 ref={autoFocus}525 disabled={saving()}526 autocomplete="new-password"527 />528 <button529 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"530 type="button"531 onClick={() => setShowPassword(!showPassword())}532 aria-label={showPassword() ? "Hide" : "Show"}533 >534 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>535 <EyeClosedGlyph />536 </Show>537 </button>538 </div>539 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />540 <button541 class="peach-generate-toggle-link"542 type="button"543 onClick={() => setShowGenerator(!showGenerator())}544 >545 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">546 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />547 <path d="M9.5 2.5H11V1" />548 </svg>549 {showGenerator() ? "hide generator" : "generate"}550 </button>551 <Show when={showGenerator()}>552 <GeneratorDrawer553 open={true}554 compact={true}555 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}556 />557 </Show>558 </div>559 </Show>560 </div>561562 </section>563 </Show>564565 {}566 <Show when={entry().type !== "note"}>567 <Show568 when={entry().notes || editingField() === "notes"}569 fallback={570 <button571 class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"572 onClick={() => startEdit("notes", "")}573 >574 + add notes575 </button>576 }577 >578 <div style="position:relative;z-index:1;margin-top:6px;">579 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>580 <Show581 when={editingField() === "notes"}582 fallback={583 <div584 class="peach-note-content peach-inline-clickable"585 style="cursor:text;white-space:pre-wrap;"586 onClick={() => startEdit("notes", entry().notes ?? "")}587 >588 {entry().notes}589 </div>590 }591 >592 <div class="peach-inline-field-wrap">593 <textarea594 class="peach-inline-textarea"595 value={draft()}596 onInput={(e) => setDraft(e.currentTarget.value)}597 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}598 ref={(el) => requestAnimationFrame(() => el?.focus())}599 rows={4}600 disabled={saving()}601 />602 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />603 </div>604 </Show>605 </div>606 </Show>607 </Show>608609 {}610 <Show when={totpState()}>611 {(state) => (612 <section class="peach-totp-band">613 <div class="peach-totp-main">614 <span class="peach-label">2-factor</span>615 <div class="peach-totp-code-row">616 <p class="peach-totp-code">617 <span class="peach-totp-code-text">618 {state().code.slice(0, 3)} {state().code.slice(3)}619 </span>620 </p>621 <button622 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}623 onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}624 aria-label="Copy TOTP code"625 >626 <CopyGlyph /><span class="peach-copy-sheen" />627 </button>628 </div>629 </div>630 <div class="peach-totp-meta">631 <span class="peach-label">expires</span>632 <div class="peach-totp-countdown">{state().remaining}s</div>633 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">634 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />635 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"636 stroke-dasharray="106.8"637 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}638 />639 </svg>640 </div>641 </section>642 )}643 </Show>644645 {}646 <div style="height:4px;" />647 </>648 )}649 </Show>650 </div>651 </>652 );653}654655656657function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {658 return (659 <div class="peach-inline-actions">660 <button661 class="peach-inline-save-btn"662 onClick={props.onSave}663 disabled={props.saving}664 aria-label="Save"665 title="Save (Enter)"666 >667 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">668 <path d="M1 4.5L4 7.5L10 1.5" />669 </svg>670 </button>671 <button672 class="peach-inline-cancel-btn"673 onClick={props.onCancel}674 disabled={props.saving}675 aria-label="Cancel"676 title="Cancel (Esc)"677 >678 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">679 <path d="M1 1L8 8M8 1L1 8" />680 </svg>681 </button>682 </div>683 );684}685686function EmptyVaultState() {687 return (688 <>689 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">690 <div class="peach-entry-name">691 <span class="peach-entry-name-text">692 <span class="peach-entry-name-char" style="--char-index:0">e</span>693 <span class="peach-entry-name-char" style="--char-index:1">m</span>694 <span class="peach-entry-name-char" style="--char-index:2">p</span>695 <span class="peach-entry-name-char" style="--char-index:3">t</span>696 <span class="peach-entry-name-char" style="--char-index:4">y</span>697 </span>698 </div>699 </div>700 <div class="peach-empty-copy">701 <span class="peach-label">vault</span>702 <p>Your vault is ready for its first entry.</p>703 </div>704 </>705 );706}707708function CopyGlyph() {709 return (710 <span class="peach-copy-icon">711 <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">712 <rect x="4" y="4" width="8" height="9" rx="1.5" />713 <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" />714 </svg>715 </span>716 );717}718719function EyeClosedGlyph() {720 return (721 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">722 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />723 <path d="M3.5 5.5L2.5 7.5" />724 <path d="M7 6.5V8.5" />725 <path d="M10.5 5.5L11.5 7.5" />726 </svg>727 );728}729730function EyeOpenGlyph() {731 return (732 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">733 <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" />734 <circle cx="7" cy="5" r="1.75" />735 </svg>736 );737}738739function ChevronGlyph(props: { open: boolean }) {740 return (741 <svg742 width="8"743 height="8"744 viewBox="0 0 8 8"745 fill="none"746 stroke="currentColor"747 stroke-width="1.3"748 stroke-linecap="round"749 stroke-linejoin="round"750 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}751 >752 <path d="M2 1L6 4L2 7" />753 </svg>754 );755}756757function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {758 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));759 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");760 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");761 const [testResult, setTestResult] = createSignal<string | null>(null);762 const [testing, setTesting] = createSignal(false);763764 async function save() {765 const url = webhookUrl().trim();766 const nextLabel = label().trim() || undefined;767 const canary = enabled() && url768 ? {769 webhookUrl: url,770 label: nextLabel,771 lastPinged: props.entry.canary?.lastPinged,772 pingCount: props.entry.canary?.pingCount,773 }774 : undefined;775 await sendMessage({776 type: "UPDATE_ENTRY",777 payload: { id: props.entry.id, patch: { canary } },778 });779 await props.onSaved();780 }781782 async function testPing() {783 const url = webhookUrl().trim();784 if (!url) return;785 setTesting(true);786 setTestResult(null);787 const res = await sendMessage({788 type: "FIRE_CANARY_PING",789 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },790 }) as { ok: boolean; error?: string };791 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);792 setTesting(false);793 }794795 return (796 <div class="peach-canary-section">797 <div class="peach-field-value-row">798 <span class="peach-field-value" style="font-size:12px;">canary alert</span>799 <button800 class={`peach-toggle ${enabled() ? "is-on" : ""}`}801 onClick={() => { setEnabled(!enabled()); void save(); }}802 aria-label="Toggle canary"803 >804 <span class="peach-toggle-knob" />805 </button>806 </div>807 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">808 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.809 </p>810 <Show when={enabled()}>811 <div class="peach-inline-field-wrap" style="margin-top:8px;">812 <input813 class="peach-inline-input"814 placeholder="https://hooks.zapier.com/..."815 value={webhookUrl()}816 onInput={(e) => setWebhookUrl(e.currentTarget.value)}817 onBlur={() => void save()}818 />819 <input820 class="peach-inline-input"821 placeholder="label (optional, e.g. AWS decoy)"822 value={label()}823 onInput={(e) => setLabel(e.currentTarget.value)}824 onBlur={() => void save()}825 />826 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">827 <button828 class="peach-btn peach-btn--ghost peach-btn--sm"829 onClick={() => void testPing()}830 disabled={testing() || !webhookUrl().trim()}831 >832 {testing() ? "sending..." : "test ping"}833 </button>834 <Show when={testResult()}>835 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>836 </Show>837 </div>838 <Show when={props.entry.canary?.lastPinged}>839 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">840 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}841 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts842 </p>843 </Show>844 </div>845 </Show>846 </div>847 );848}