1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";1213type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1415interface EntryProps {16 entry: VaultEntry | null;17 1819202122 currentTabUrl?: string;23 onDelete: () => void;24 25 onFieldSaved: () => void | Promise<void>;26 onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30 const [editingField, setEditingField] = createSignal<EditableField>(null);31 const [draft, setDraft] = createSignal("");32 const [saving, setSaving] = createSignal(false);33 const [showPassword, setShowPassword] = createSignal(false);34 const [showGenerator, setShowGenerator] = createSignal(false);35 const [copied, setCopied] = createSignal<string | null>(null);36 37 38 const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39 const [editingTags, setEditingTags] = createSignal(false);40 const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41 const [tagInput, setTagInput] = createSignal("");42 const [showCardNumber, setShowCardNumber] = createSignal(false);43 const [showCardCvc, setShowCardCvc] = createSignal(false);44 const [totpTick, setTotpTick] = createSignal(0);45 const [confirmDelete, setConfirmDelete] = createSignal(false);46 const [totpHot, setTotpHot] = createSignal(false);47 const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48 const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49 50 const [showAdvanced, setShowAdvanced] = createSignal(false);51 let lastSavedNote: string | null = null;5253 function noteExec(cmd: string, val?: string) {54 document.execCommand(cmd, false, val);55 noteEditorEl()?.focus();56 }5758 async function saveNoteInline(html: string) {59 const entry = props.entry;60 if (!entry) return;61 const currentStored = entry.notes ?? "";62 if (html === currentStored) return;63 if (lastSavedNote !== null && html === lastSavedNote) return;64 lastSavedNote = html;65 setSaving(true);66 try {67 await sendMessage({68 type: "UPDATE_ENTRY",69 payload: { id: entry.id, patch: { notes: html } },70 });71 triggerRing();72 await props.onFieldSaved();73 } catch {74 lastSavedNote = null;75 } finally {76 setSaving(false);77 }78 }7980 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81 onCleanup(() => window.clearInterval(interval));8283 const displayDomain = createMemo(() =>84 props.entry?.url ? getDisplayDomain(props.entry.url) : ""85 );8687 createEffect(() => {88 const domain = displayDomain();89 setFaviconSrc(domain ? faviconUrl(domain) : null);90 });9192 93 94 95 96 97 let lastEntryId: string | null = null;98 createEffect(() => {99 const id = props.entry?.id ?? null;100 if (id === lastEntryId) return;101 lastEntryId = id;102 setConfirmDelete(false);103 setEditingField(null);104 setEditingTags(false);105 setShowAdvanced(false);106 });107108 const totpState = createMemo(() => {109 void totpTick();110 if (!props.entry?.totp) return null;111 return generateTOTP(props.entry.totp);112 });113114 let prevTotpCode: string | undefined;115 createEffect(() => {116 const state = totpState();117 if (!state) { prevTotpCode = undefined; return; }118 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {119 setTotpHot(true);120 window.setTimeout(() => setTotpHot(false), 3000);121 }122 prevTotpCode = state.code;123 });124125 const totpProgress = createMemo(() => {126 const state = totpState();127 if (!state) return 0;128 return ((state.remaining - 1) / 29) * 100;129 });130131 createEffect(() => {132 const shell = document.getElementById("hd-shell");133 if (!shell) return;134135 const state = totpState();136 if (!state) {137 delete shell.dataset.totpActive;138 shell.style.removeProperty("--peach-totp-progress");139 return;140 }141142 shell.dataset.totpActive = "true";143 shell.style.setProperty(144 "--peach-totp-progress",145 `${Math.max(0, totpProgress())}%`146 );147148 onCleanup(() => {149 delete shell.dataset.totpActive;150 shell.style.removeProperty("--peach-totp-progress");151 });152 });153154 const titleSizeClass = createMemo(() => {155 const length = props.entry?.title.length ?? 0;156 if (length >= 28) return "peach-entry-name--xxl";157 if (length >= 20) return "peach-entry-name--xl";158 if (length >= 14) return "peach-entry-name--lg";159 return "";160 });161 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);162163 164165 function startEdit(field: EditableField, value: string) {166 setEditingField(field);167 setDraft(value);168 setShowGenerator(false);169 setConfirmDelete(false);170 if (field === "url") {171 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);172 }173 }174175 176 177 178 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {179 const c = candidate.trim();180 if (!c) return true;181 if (primary.trim() === c) return true;182 return others.some((u) => u.trim() === c);183 }184185 function addCurrentSite(url: string) {186 if (!url) return;187 if (!draft().trim()) {188 setDraft(url);189 return;190 }191 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;192 setAdditionalUrlsDraft((arr) => [...arr, url]);193 }194195 async function saveField() {196 const field = editingField();197 const entry = props.entry;198 if (!field || !entry) return;199 200 let value = field === "password" ? draft() : draft().trim();201 if (field === "totp" && value.startsWith("otpauth://")) {202 const parsed = parseOtpauthURI(value);203 value = parsed?.secret ?? value;204 }205 if (field === "password" && entry.type !== "note" && !value) {206 return;207 }208 setSaving(true);209 try {210 const patch: Partial<VaultEntry> = { [field]: value };211 if (field === "url") {212 213 patch.additionalUrls = additionalUrlsDraft();214 }215 await sendMessage({216 type: "UPDATE_ENTRY",217 payload: { id: entry.id, patch },218 });219 triggerRing();220 setEditingField(null);221 setShowGenerator(false);222 await props.onFieldSaved();223 } catch {224 225 } finally {226 setSaving(false);227 }228 }229230 function startEditTags() {231 setTagsDraft([...(props.entry?.tags ?? [])]);232 setTagInput("");233 setEditingTags(true);234 }235236 function addTag(value: string) {237 const n = value.toLowerCase().trim();238 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;239 setTagsDraft([...tagsDraft(), n]);240 setTagInput("");241 }242243 async function saveTags() {244 if (tagInput()) addTag(tagInput());245 const entry = props.entry;246 if (!entry) return;247 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;248 setEditingTags(false);249 setSaving(true);250 try {251 await sendMessage({252 type: "UPDATE_ENTRY",253 payload: { id: entry.id, patch: { tags: next } },254 });255 triggerRing();256 await props.onFieldSaved();257 } catch {258 259 } finally {260 setSaving(false);261 }262 }263264 function cancelEdit() {265 setEditingField(null);266 setDraft("");267 setShowGenerator(false);268 }269270 271 272 273 274 275 276 277 278 279 async function addOtherIdentity(target: "username" | "email") {280 const entry = props.entry;281 if (!entry) return;282 const current = editingField();283 const draftValue = draft().trim();284 const savedUsername = entry.username ?? "";285 const savedEmail = entry.email ?? "";286287 if (current === "username" && target === "username") {288 289 290 if (savedUsername.includes("@") && !entry.email) {291 setSaving(true);292 try {293 await sendMessage({294 type: "UPDATE_ENTRY",295 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },296 });297 await props.onFieldSaved();298 } finally {299 setSaving(false);300 }301 }302 startEdit("username", "");303 return;304 }305306 if (current === "username" && target === "email") {307 if (draftValue && draftValue !== savedUsername) {308 setSaving(true);309 try {310 await sendMessage({311 type: "UPDATE_ENTRY",312 payload: { id: entry.id, patch: { username: draftValue } },313 });314 await props.onFieldSaved();315 } finally {316 setSaving(false);317 }318 }319 startEdit("email", "");320 return;321 }322323 if (current === "email" && target === "username") {324 if (draftValue && draftValue !== savedEmail) {325 setSaving(true);326 try {327 await sendMessage({328 type: "UPDATE_ENTRY",329 payload: { id: entry.id, patch: { email: draftValue } },330 });331 await props.onFieldSaved();332 } finally {333 setSaving(false);334 }335 }336 startEdit("username", "");337 return;338 }339 }340341 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {342 switch (field) {343 case "title":344 return entry.title;345 case "url":346 return entry.url ?? "";347 case "username":348 return entry.username ?? "";349 case "email":350 return entry.email ?? "";351 case "password":352 return entry.password ?? "";353 case "notes":354 return entry.notes ?? "";355 case "totp":356 return entry.totp ?? "";357 }358 }359360 function handleEditorFocusOut(361 field: Exclude<EditableField, null>,362 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }363 ) {364 const nextTarget = event.relatedTarget;365 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;366367 const entry = props.entry;368 if (!entry) return;369370 const currentValue = fieldValue(entry, field);371 const nextValue = field === "password" ? draft() : draft().trim();372 if (nextValue === currentValue) {373 cancelEdit();374 }375 }376377 async function savePassword(password: string) {378 const entry = props.entry;379 if (!entry) return;380 setSaving(true);381 try {382 await sendMessage({383 type: "UPDATE_ENTRY",384 payload: { id: entry.id, patch: { password } },385 });386 triggerRing();387 setShowGenerator(false);388 await props.onFieldSaved();389 } catch {390 391 } finally {392 setSaving(false);393 }394 }395396 397398 async function copyField(text: string, label: string, button?: HTMLButtonElement) {399 const ok = await popupCopy(text, label);400 if (ok) {401 const entry = props.entry;402 if (entry?.canary?.webhookUrl) {403 const trigger =404 label === "password"405 ? "password_copied"406 : label === "username"407 ? "username_copied"408 : undefined;409 if (trigger) {410 void sendMessage({411 type: "FIRE_CANARY_PING",412 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },413 });414 }415 }416 triggerRing();417 triggerPipPulse();418 setCopied(null);419 requestAnimationFrame(() => setCopied(label));420 if (button) {421 button.classList.remove("is-copied", "is-retreat");422 void button.offsetWidth;423 const drain = document.createElement("span");424 drain.className = "peach-copy-drain";425 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";426 drain.appendChild(bar);427 button.appendChild(drain);428 window.setTimeout(() => {429 button.classList.remove("is-copied");430 button.classList.add("is-retreat");431 drain.remove();432 window.setTimeout(() => button.classList.remove("is-retreat"), 160);433 }, 1800);434 }435 window.setTimeout(() => setCopied(null), 1800);436 }437 }438439 440441 createEffect(() => {442 if (!props.entry) return;443 const handler = (e: KeyboardEvent) => {444 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;445 if (inInput) return;446 switch (e.key) {447 case "c": e.preventDefault();448 if (props.entry?.password) void copyField(props.entry.password, "password"); break;449 case "u": e.preventDefault();450 if (props.entry?.username) void copyField(props.entry.username, "username"); break;451 case "Escape": e.preventDefault(); props.onEscape?.(); break;452 }453 };454 window.addEventListener("keydown", handler);455 onCleanup(() => window.removeEventListener("keydown", handler));456 });457458 459460 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {461 requestAnimationFrame(() => { el.focus(); el.select?.(); });462 }463464 function onEditKeyDown(e: KeyboardEvent) {465 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {466 e.preventDefault();467 void saveField();468 }469 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }470 }471472 return (473 <>474 <div475 class="peach-ghost"476 aria-hidden="true"477 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;"478 >479 {props.entry?.title || "empty"}480 </div>481482 <div class="peach-stage" style="position:relative;">483 <Show when={props.entry} fallback={<EmptyVaultState />}>484 {(entry) => (485 <>486 {}487 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>488 <div class="peach-breach-badge">489 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">490 <path d="M6 1L11 10H1L6 1z" />491 <line x1="6" y1="5" x2="6" y2="7.5" />492 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />493 </svg>494 <span style="flex:1">found in a known data breach</span>495 <button496 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"497 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}498 >499 Change password500 </button>501 </div>502 </Show>503504 {}505 <div class="peach-entry-header">506 <div class="peach-entry-header-name">507 <Show508 when={editingField() === "title"}509 fallback={510 <button511 class="peach-entry-name-button"512 title="Click to edit title"513 onClick={() => startEdit("title", entry().title)}514 >515 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>516 <span class="peach-entry-name-text">517 <Show518 when={useAnimatedTitle()}519 fallback={entry().title}520 >521 <For each={entry().title.split("")}>522 {(ch, i) => (523 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>524 {ch === " " ? "\u00A0" : ch}525 </span>526 )}527 </For>528 </Show>529 </span>530 </h1>531 </button>532 }533 >534 <div535 class="peach-inline-title-wrap"536 onFocusOut={(event) => handleEditorFocusOut("title", event)}537 >538 <input539 class="peach-inline-title-input"540 value={draft()}541 onInput={(e) => setDraft(e.currentTarget.value)}542 onKeyDown={onEditKeyDown}543 ref={autoFocus}544 disabled={saving()}545 />546 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />547 </div>548 </Show>549 </div>550551 {}552 <Show when={editingField() !== "title"}>553 <Show554 when={confirmDelete()}555 fallback={556 <button557 class="peach-entry-trash-btn"558 onClick={() => setConfirmDelete(true)}559 aria-label="Move to trash"560 title="Delete entry"561 >562 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">563 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />564 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />565 </svg>566 </button>567 }568 >569 <div class="peach-entry-trash-confirm">570 <span class="peach-entry-trash-confirm-label">trash?</span>571 <button572 class="peach-btn peach-btn--sm peach-btn--danger"573 style="font-size:10px;padding:2px 8px;min-height:22px;"574 onClick={() => { setConfirmDelete(false); props.onDelete(); }}575 >576 yes577 </button>578 <button579 class="peach-btn peach-btn--ghost peach-btn--sm"580 style="font-size:10px;padding:2px 8px;min-height:22px;"581 onClick={() => setConfirmDelete(false)}582 >583 no584 </button>585 </div>586 </Show>587 </Show>588 </div>589590 {}591 <Show when={entry().type !== "note" && entry().type !== "card"}>592 <div class="peach-domain" style="position:relative;z-index:1;">593 <Show594 when={editingField() === "url"}595 fallback={596 <div597 class="peach-domain-row peach-inline-clickable"598 title="Click to edit URL"599 onClick={() => startEdit("url", entry().url ?? "")}600 >601 <Show when={faviconSrc()}>602 <img603 src={faviconSrc()!}604 width="16"605 height="16"606 alt=""607 style="border-radius:3px;margin-right:5px;flex-shrink:0;"608 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}609 />610 </Show>611 <span class="peach-domain-value">612 {displayDomain() || (entry().url ? entry().url : "add a website url")}613 </span>614 <span class="peach-inline-edit-hint">edit</span>615 </div>616 }617 >618 <div619 class="peach-inline-field-wrap peach-inline-field-wrap--url"620 onFocusOut={(event) => handleEditorFocusOut("url", event)}621 >622 <div class="peach-inline-field-row">623 <input624 class="peach-inline-input"625 type="url"626 placeholder="https://"627 value={draft()}628 onInput={(e) => setDraft(e.currentTarget.value)}629 onKeyDown={onEditKeyDown}630 ref={autoFocus}631 disabled={saving()}632 />633 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />634 </div>635 <div class="peach-additional-urls">636 <Show when={additionalUrlsDraft().length > 0}>637 <span class="peach-label peach-additional-urls-label">also matches</span>638 </Show>639 <For each={additionalUrlsDraft()}>640 {(value, i) => (641 <div class="peach-additional-url-row">642 <input643 class="peach-inline-input"644 type="url"645 placeholder="https://"646 value={value}647 onInput={(e) => setAdditionalUrlsDraft((arr) => {648 const next = [...arr];649 next[i()] = e.currentTarget.value;650 return next;651 })}652 disabled={saving()}653 />654 <button655 class="peach-additional-url-remove"656 type="button"657 aria-label="Remove URL"658 onMouseDown={(e) => e.preventDefault()}659 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}660 disabled={saving()}661 >×</button>662 </div>663 )}664 </For>665 <button666 class="peach-add-field-link peach-additional-urls-add"667 type="button"668 onMouseDown={(e) => e.preventDefault()}669 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}670 disabled={saving()}671 >672 + add another url673 </button>674 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>675 <button676 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"677 type="button"678 title={props.currentTabUrl}679 onMouseDown={(e) => e.preventDefault()}680 onClick={() => addCurrentSite(props.currentTabUrl!)}681 disabled={saving()}682 >683 + add current site684 </button>685 </Show>686 </div>687 </div>688 </Show>689 </div>690 </Show>691692 {}693 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>694 <div695 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"696 onClick={startEditTags}697 title="Click to edit tags"698 >699 <For each={entry().tags!}>700 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}701 </For>702 </div>703 </Show>704705 {}706 <Show when={entry().type === "note"}>707 <div class="peach-entry-note-toolbar-row">708 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />709 </div>710 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">711 <NoteEditorInline712 entryKey={entry().id}713 initialContent={entry().notes ?? ""}714 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}715 onBlur={saveNoteInline}716 autoFocus={false}717 />718 </section>719 </Show>720721 {}722 <Show when={entry().type === "card"}>723 <section class="peach-fields" style="position:relative;z-index:1;">724 {}725 <Show when={entry().last4}>726 <div class="peach-field-card" style="padding-bottom:4px;">727 <span class="peach-label">728 <Show when={entry().network && entry().network !== "unknown"}>729 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>730 </Show>731 {entry().cardNickname || `•••• ${entry().last4}`}732 </span>733 </div>734 </Show>735736 {}737 <div class="peach-field-card">738 <span class="peach-label">cardholder name</span>739 <div class="peach-field-value-row">740 <span class="peach-field-value">{entry().cardholderName || "—"}</span>741 <Show when={entry().cardholderName}>742 <button743 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}744 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}745 aria-label="Copy cardholder name"746 >747 <CopyGlyph /><span class="peach-copy-sheen" />748 </button>749 </Show>750 </div>751 </div>752753 {}754 <div class="peach-field-card">755 <span class="peach-label">card number</span>756 <div class="peach-field-value-row">757 <span class={`peach-field-value peach-card-number`}>758 <Show when={showCardNumber()} fallback={759 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>760 }>761 <span>{entry().cardNumber ?? ""}</span>762 </Show>763 </span>764 <div class="peach-field-actions">765 <button766 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"767 onClick={() => setShowCardNumber(!showCardNumber())}768 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}769 >770 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>771 <EyeClosedGlyph />772 </Show>773 </button>774 <Show when={entry().cardNumber}>775 <button776 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}777 onClick={(e) => {778 const cardNum = entry().cardNumber!;779 if (entry().canary?.webhookUrl) {780 void sendMessage({781 type: "FIRE_CANARY_PING",782 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },783 });784 }785 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);786 }}787 aria-label="Copy card number"788 >789 <CopyGlyph /><span class="peach-copy-sheen" />790 </button>791 </Show>792 </div>793 </div>794 </div>795796 {}797 <Show when={entry().expMonth || entry().expYear}>798 <div class="peach-field-card">799 <span class="peach-label">expiry</span>800 <div class="peach-field-value-row">801 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>802 <button803 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}804 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}805 aria-label="Copy expiry"806 >807 <CopyGlyph /><span class="peach-copy-sheen" />808 </button>809 </div>810 </div>811 </Show>812813 {}814 <Show when={entry().cvc}>815 <div class="peach-field-card">816 <span class="peach-label">CVC</span>817 <div class="peach-field-value-row">818 <span class="peach-field-value">819 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>820 <span>{entry().cvc}</span>821 </Show>822 </span>823 <div class="peach-field-actions">824 <button825 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"826 onClick={() => setShowCardCvc(!showCardCvc())}827 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}828 >829 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>830 <EyeClosedGlyph />831 </Show>832 </button>833 <button834 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}835 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}836 aria-label="Copy CVC"837 >838 <CopyGlyph /><span class="peach-copy-sheen" />839 </button>840 </div>841 </div>842 </div>843 </Show>844 </section>845 </Show>846847 {}848 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>849 <section class="peach-fields" style="position:relative;z-index:1;">850851 {852853}854 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>855 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>856 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>857 <Show858 when={editingField() === "username"}859 fallback={860 <div class="peach-field-value-row">861 <span862 class="peach-field-value peach-inline-clickable"863 title="Click to edit"864 onClick={() => startEdit("username", entry().username ?? "")}865 >866 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}867 </span>868 <Show when={entry().username}>869 <button870 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}871 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}872 aria-label="Copy username"873 >874 <CopyGlyph /><span class="peach-copy-sheen" />875 </button>876 </Show>877 </div>878 }879 >880 <div881 class="peach-inline-field-wrap"882 onFocusOut={(event) => handleEditorFocusOut("username", event)}883 >884 <input885 class="peach-inline-input"886 value={draft()}887 onInput={(e) => setDraft(e.currentTarget.value)}888 onKeyDown={onEditKeyDown}889 ref={autoFocus}890 disabled={saving()}891 autocomplete="off"892 />893 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />894 {895896897}898 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>899 <button900 class="peach-add-field-btn"901 type="button"902 onMouseDown={(e) => e.preventDefault()}903 onClick={() => {904 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);905 const hasSeparateEmail = !!entry().email;906 if (isEmailShape && !hasSeparateEmail) {907 void addOtherIdentity("username");908 } else {909 void addOtherIdentity("email");910 }911 }}912 >913 {(() => {914 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);915 const hasSeparateEmail = !!entry().email;916 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";917 })()}918 </button>919 </Show>920 </div>921 </Show>922 </div>923 </Show>924925 {926}927 <Show when={entry().email || editingField() === "email"}>928 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>929 <span class="peach-label">email</span>930 <Show931 when={editingField() === "email"}932 fallback={933 <div class="peach-field-value-row">934 <span935 class="peach-field-value peach-inline-clickable"936 title="Click to edit"937 onClick={() => startEdit("email", entry().email ?? "")}938 >939 {entry().email}940 </span>941 <Show when={entry().email}>942 <button943 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}944 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}945 aria-label="Copy email"946 >947 <CopyGlyph /><span class="peach-copy-sheen" />948 </button>949 </Show>950 </div>951 }952 >953 <div954 class="peach-inline-field-wrap"955 onFocusOut={(event) => handleEditorFocusOut("email", event)}956 >957 <input958 class="peach-inline-input"959 value={draft()}960 onInput={(e) => setDraft(e.currentTarget.value)}961 onKeyDown={onEditKeyDown}962 ref={autoFocus}963 disabled={saving()}964 autocomplete="email"965 type="email"966 />967 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />968 <Show when={!entry().username || entry().username?.includes("@")}>969 <button970 class="peach-add-field-btn"971 type="button"972 onMouseDown={(e) => e.preventDefault()}973 onClick={() => void addOtherIdentity("username")}974 >975 + add username976 </button>977 </Show>978 </div>979 </Show>980 </div>981 </Show>982983 {}984 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>985 <span class="peach-label">password</span>986 <Show987 when={editingField() === "password"}988 fallback={989 <div class="peach-field-value-row">990 <span991 class="peach-password-value"992 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}993 style="cursor:pointer;"994 title="Click to edit password"995 onClick={() => startEdit("password", entry().password ?? "")}996 >997 <Show998 when={showPassword()}999 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1000 >1001 <span class="peach-password-text">{entry().password ?? ""}</span>1002 </Show>1003 </span>1004 <div class="peach-field-actions">1005 <button1006 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1007 onClick={() => setShowPassword(!showPassword())}1008 aria-label="Reveal password"1009 >1010 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1011 <EyeClosedGlyph />1012 </Show>1013 </button>1014 <button1015 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1016 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1017 aria-label="Copy password"1018 >1019 <CopyGlyph /><span class="peach-copy-sheen" />1020 </button>1021 </div>1022 </div>1023 }1024 >1025 {}1026 <div1027 class="peach-inline-field-wrap"1028 onFocusOut={(event) => handleEditorFocusOut("password", event)}1029 >1030 <div class="peach-password-edit-row">1031 <input1032 class="peach-inline-input"1033 type={showPassword() ? "text" : "password"}1034 placeholder="paste or type a password"1035 value={draft()}1036 onInput={(e) => setDraft(e.currentTarget.value)}1037 onKeyDown={(e) => {1038 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1039 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1040 }}1041 ref={autoFocus}1042 disabled={saving()}1043 autocomplete="new-password"1044 />1045 <button1046 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1047 type="button"1048 onClick={() => setShowPassword(!showPassword())}1049 aria-label={showPassword() ? "Hide" : "Show"}1050 >1051 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1052 <EyeClosedGlyph />1053 </Show>1054 </button>1055 </div>1056 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1057 <button1058 class="peach-generate-toggle-link"1059 type="button"1060 onClick={() => setShowGenerator(!showGenerator())}1061 >1062 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1063 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1064 <path d="M9.5 2.5H11V1" />1065 </svg>1066 {showGenerator() ? "hide generator" : "generate"}1067 </button>1068 <Show when={showGenerator()}>1069 <GeneratorDrawer1070 open={true}1071 compact={true}1072 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1073 />1074 </Show>1075 </div>1076 </Show>1077 </div>10781079 </section>1080 </Show>10811082 {}1083 <Show when={entry().type !== "note" && entry().type !== "card"}>1084 <Show1085 when={entry().notes || editingField() === "notes"}1086 fallback={1087 <button1088 class="peach-add-field-link peach-entry-add-link"1089 onClick={() => startEdit("notes", "")}1090 >1091 + notes1092 </button>1093 }1094 >1095 <div style="position:relative;z-index:1;margin-top:6px;">1096 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1097 <Show1098 when={editingField() === "notes"}1099 fallback={1100 <div1101 class="peach-note-content peach-inline-clickable"1102 style="cursor:text;white-space:pre-wrap;"1103 onClick={() => startEdit("notes", entry().notes ?? "")}1104 >1105 {entry().notes}1106 </div>1107 }1108 >1109 <div1110 class="peach-inline-field-wrap"1111 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1112 >1113 <textarea1114 class="peach-inline-textarea"1115 value={draft()}1116 onInput={(e) => setDraft(e.currentTarget.value)}1117 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1118 ref={(el) => requestAnimationFrame(() => el?.focus())}1119 rows={4}1120 disabled={saving()}1121 />1122 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1123 </div>1124 </Show>1125 </div>1126 </Show>1127 </Show>11281129 {}1130 <Show1131 when={editingTags()}1132 fallback={1133 <Show when={!entry().tags || entry().tags!.length === 0}>1134 <button1135 class="peach-add-field-link peach-entry-add-link"1136 onClick={startEditTags}1137 >1138 + tags1139 </button>1140 </Show>1141 }1142 >1143 <div1144 class="peach-inline-tag-editor"1145 onFocusOut={(e) => {1146 const next = e.relatedTarget;1147 if (next instanceof Node && e.currentTarget.contains(next)) return;1148 void saveTags();1149 }}1150 >1151 <span class="peach-label">tags</span>1152 <div class="peach-tag-input-wrap">1153 <For each={tagsDraft()}>1154 {(tag) => (1155 <span class="peach-tag-chip">1156 {tag}1157 <button1158 class="peach-tag-chip-remove"1159 onMouseDown={(e) => e.preventDefault()}1160 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1161 aria-label={`Remove ${tag}`}1162 >×</button>1163 </span>1164 )}1165 </For>1166 <input1167 class="peach-tag-input"1168 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1169 value={tagInput()}1170 onInput={(e) => setTagInput(e.currentTarget.value)}1171 onKeyDown={(e) => {1172 if (e.key === "Enter" || e.key === ",") {1173 e.preventDefault();1174 addTag(tagInput());1175 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1176 setTagsDraft(tagsDraft().slice(0, -1));1177 } else if (e.key === "Escape") {1178 e.preventDefault();1179 setEditingTags(false);1180 setTagInput("");1181 }1182 }}1183 ref={(el) => requestAnimationFrame(() => el?.focus())}1184 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1185 />1186 </div>1187 </div>1188 </Show>11891190 {}1191 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1192 <Show1193 when={totpState() || editingField() === "totp"}1194 fallback={1195 <button1196 class="peach-add-field-link peach-entry-add-link"1197 onClick={() => startEdit("totp", "")}1198 >1199 + 2FA1200 </button>1201 }1202 >1203 <section class="peach-totp-band">1204 <Show1205 when={editingField() === "totp"}1206 fallback={1207 <Show when={totpState()}>1208 {(state) => (1209 <>1210 <div1211 class="peach-totp-main"1212 onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1213 onDblClick={() => startEdit("totp", entry().totp ?? "")}1214 title="Click to copy · double-click to edit"1215 >1216 <span class="peach-label">2-factor</span>1217 <div class="peach-totp-code-row">1218 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1219 <span class="peach-totp-code-text">1220 {state().code.slice(0, 3)} {state().code.slice(3)}1221 </span>1222 </p>1223 </div>1224 </div>1225 <div class="peach-totp-meta">1226 <span class="peach-label">expires</span>1227 <div class="peach-totp-countdown">{state().remaining}s</div>1228 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1229 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1230 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1231 stroke-dasharray="106.8"1232 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1233 />1234 </svg>1235 </div>1236 </>1237 )}1238 </Show>1239 }1240 >1241 <div1242 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1243 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1244 >1245 <span class="peach-label">2-factor</span>1246 <input1247 class="peach-inline-input"1248 value={draft()}1249 onInput={(e) => setDraft(e.currentTarget.value)}1250 onKeyDown={onEditKeyDown}1251 ref={autoFocus}1252 disabled={saving()}1253 placeholder="otpauth://... or base32 secret"1254 autocomplete="off"1255 />1256 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1257 </div>1258 </Show>1259 </section>1260 </Show>1261 </Show>12621263 {}1264 <Show when={entry().type === "login"}>1265 <button1266 class="peach-add-field-link peach-entry-add-link"1267 onClick={() => setShowAdvanced((v) => !v)}1268 aria-expanded={showAdvanced()}1269 >1270 {showAdvanced() ? "− Advanced" : "+ Advanced"}1271 </button>1272 <Show when={showAdvanced()}>1273 <Show when={!isPasskeyOnly(entry())}>1274 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1275 </Show>12761277 <Show when={entry().passkey}>1278 {(passkey) => {1279 const currentBrowser = detectBrowser();1280 const crossBrowser =1281 passkey().browser &&1282 currentBrowser !== "unknown" &&1283 passkey().browser !== currentBrowser;1284 const algorithmLabel =1285 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1286 const formatDate = (ts?: number) =>1287 ts ? new Date(ts).toLocaleDateString() : "Never";1288 return (1289 <section1290 class="peach-fields peach-passkey-section"1291 style="position:relative;z-index:1;margin-top:6px;"1292 >1293 <div class="peach-field-card">1294 <span class="peach-label">passkey · {passkey().rpId}</span>1295 <div class="peach-field-value-row">1296 <span class="peach-field-value">{passkey().userName}</span>1297 <span class="peach-passkey-badge">passkey</span>1298 </div>1299 </div>13001301 <div class="peach-field-card">1302 <span class="peach-label">algorithm</span>1303 <div class="peach-field-value-row">1304 <span class="peach-field-value">{algorithmLabel}</span>1305 </div>1306 </div>13071308 <div class="peach-field-card">1309 <span class="peach-label">activity</span>1310 <div class="peach-field-value-row peach-passkey-activity">1311 <span class="peach-field-value">1312 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1313 </span>1314 <span class="peach-passkey-meta">1315 created {formatDate(passkey().createdAt)}1316 </span>1317 <span class="peach-passkey-meta">1318 last used {formatDate(passkey().lastUsedAt)}1319 </span>1320 </div>1321 </div>13221323 <Show when={crossBrowser}>1324 <div class="peach-info-notice peach-info-notice--warn">1325 {`This passkey was created on ${1326 passkey().browser === "chrome" ? "Chrome" : "Firefox"1327 }. To use it on ${1328 currentBrowser === "chrome" ? "Chrome" : "Firefox"1329 }, re-register it on this device.`}1330 </div>1331 </Show>13321333 <div class="peach-info-notice">1334 {passkey().browser === "firefox"1335 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1336 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1337 </div>13381339 <button1340 class="peach-add-field-link peach-entry-add-link"1341 style="color:rgba(220,38,38,0.7);"1342 onClick={async () => {1343 const id = entry().id;1344 await sendMessage({1345 type: "UPDATE_ENTRY",1346 payload: { id, patch: { passkey: undefined } },1347 });1348 await props.onFieldSaved();1349 }}1350 >1351 − remove passkey1352 </button>1353 </section>1354 );1355 }}1356 </Show>1357 </Show>1358 </Show>13591360 {}1361 <div style="height:4px;" />1362 </>1363 )}1364 </Show>1365 </div>13661367 </>1368 );1369}1370137113721373function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1374 return (1375 <div class="peach-inline-actions">1376 <button1377 class="peach-inline-save-btn"1378 onClick={props.onSave}1379 disabled={props.saving}1380 aria-label="Save"1381 title="Save (Enter)"1382 >1383 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1384 <path d="M1 4.5L4 7.5L10 1.5" />1385 </svg>1386 </button>1387 <button1388 class="peach-inline-cancel-btn"1389 onClick={props.onCancel}1390 disabled={props.saving}1391 aria-label="Cancel"1392 title="Cancel (Esc)"1393 >1394 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1395 <path d="M1 1L8 8M8 1L1 8" />1396 </svg>1397 </button>1398 </div>1399 );1400}14011402function EmptyVaultState() {1403 return (1404 <>1405 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1406 <div class="peach-entry-name">1407 <span class="peach-entry-name-text">1408 <span class="peach-entry-name-char" style="--char-index:0">e</span>1409 <span class="peach-entry-name-char" style="--char-index:1">m</span>1410 <span class="peach-entry-name-char" style="--char-index:2">p</span>1411 <span class="peach-entry-name-char" style="--char-index:3">t</span>1412 <span class="peach-entry-name-char" style="--char-index:4">y</span>1413 </span>1414 </div>1415 </div>1416 <div class="peach-empty-copy">1417 <span class="peach-label">vault</span>1418 <p>Your vault is ready for its first entry.</p>1419 </div>1420 </>1421 );1422}14231424function CopyGlyph() {1425 return (1426 <span class="peach-copy-icon">1427 <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">1428 <rect x="4" y="4" width="8" height="9" rx="1.5" />1429 <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" />1430 </svg>1431 </span>1432 );1433}14341435function EyeClosedGlyph() {1436 return (1437 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1438 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1439 <path d="M3.5 5.5L2.5 7.5" />1440 <path d="M7 6.5V8.5" />1441 <path d="M10.5 5.5L11.5 7.5" />1442 </svg>1443 );1444}14451446function EyeOpenGlyph() {1447 return (1448 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1449 <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" />1450 <circle cx="7" cy="5" r="1.75" />1451 </svg>1452 );1453}14541455function ChevronGlyph(props: { open: boolean }) {1456 return (1457 <svg1458 width="8"1459 height="8"1460 viewBox="0 0 8 8"1461 fill="none"1462 stroke="currentColor"1463 stroke-width="1.3"1464 stroke-linecap="round"1465 stroke-linejoin="round"1466 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1467 >1468 <path d="M2 1L6 4L2 7" />1469 </svg>1470 );1471}14721473function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1474 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1475 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1476 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1477 const [testResult, setTestResult] = createSignal<string | null>(null);1478 const [testing, setTesting] = createSignal(false);14791480 async function save() {1481 const url = webhookUrl().trim();1482 const nextLabel = label().trim() || undefined;1483 const canary = enabled() && url1484 ? {1485 webhookUrl: url,1486 label: nextLabel,1487 lastPinged: props.entry.canary?.lastPinged,1488 pingCount: props.entry.canary?.pingCount,1489 }1490 : undefined;1491 await sendMessage({1492 type: "UPDATE_ENTRY",1493 payload: { id: props.entry.id, patch: { canary } },1494 });1495 await props.onSaved();1496 }14971498 async function testPing() {1499 const url = webhookUrl().trim();1500 if (!url) return;1501 setTesting(true);1502 setTestResult(null);1503 const res = await sendMessage({1504 type: "FIRE_CANARY_PING",1505 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1506 }) as { ok: boolean; error?: string };1507 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1508 setTesting(false);1509 }15101511 return (1512 <div class="peach-canary-section">1513 <div class="peach-field-value-row">1514 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1515 <button1516 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1517 onClick={() => { setEnabled(!enabled()); void save(); }}1518 aria-label="Toggle canary"1519 >1520 <span class="peach-toggle-knob" />1521 </button>1522 </div>1523 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1524 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1525 </p>1526 <Show when={enabled()}>1527 <div class="peach-inline-field-wrap" style="margin-top:8px;">1528 <input1529 class="peach-inline-input"1530 placeholder="https://hooks.zapier.com/..."1531 value={webhookUrl()}1532 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1533 onBlur={() => void save()}1534 />1535 <input1536 class="peach-inline-input"1537 placeholder="label (optional, e.g. AWS decoy)"1538 value={label()}1539 onInput={(e) => setLabel(e.currentTarget.value)}1540 onBlur={() => void save()}1541 />1542 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1543 <button1544 class="peach-btn peach-btn--ghost peach-btn--sm"1545 onClick={() => void testPing()}1546 disabled={testing() || !webhookUrl().trim()}1547 >1548 {testing() ? "sending..." : "test ping"}1549 </button>1550 <Show when={testResult()}>1551 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1552 </Show>1553 </div>1554 <Show when={props.entry.canary?.lastPinged}>1555 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1556 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1557 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1558 </p>1559 </Show>1560 </div>1561 </Show>1562 </div>1563 );1564}