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;52 let totpClickTimer: ReturnType<typeof setTimeout> | null = null;53 onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });5455 function noteExec(cmd: string, val?: string) {56 document.execCommand(cmd, false, val);57 noteEditorEl()?.focus();58 }5960 async function saveNoteInline(html: string) {61 const entry = props.entry;62 if (!entry) return;63 const currentStored = entry.notes ?? "";64 if (html === currentStored) return;65 if (lastSavedNote !== null && html === lastSavedNote) return;66 lastSavedNote = html;67 setSaving(true);68 try {69 await sendMessage({70 type: "UPDATE_ENTRY",71 payload: { id: entry.id, patch: { notes: html } },72 });73 triggerRing();74 await props.onFieldSaved();75 } catch {76 lastSavedNote = null;77 } finally {78 setSaving(false);79 }80 }8182 const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);83 onCleanup(() => window.clearInterval(interval));8485 const displayDomain = createMemo(() =>86 props.entry?.url ? getDisplayDomain(props.entry.url) : ""87 );8889 createEffect(() => {90 const domain = displayDomain();91 setFaviconSrc(domain ? faviconUrl(domain) : null);92 });9394 95 96 97 98 99 let lastEntryId: string | null = null;100 createEffect(() => {101 const id = props.entry?.id ?? null;102 if (id === lastEntryId) return;103 lastEntryId = id;104 setConfirmDelete(false);105 setEditingField(null);106 setEditingTags(false);107 setShowAdvanced(false);108 });109110 const totpState = createMemo(() => {111 void totpTick();112 if (!props.entry?.totp) return null;113 return generateTOTP(props.entry.totp);114 });115116 let prevTotpCode: string | undefined;117 createEffect(() => {118 const state = totpState();119 if (!state) { prevTotpCode = undefined; return; }120 if (prevTotpCode !== undefined && state.code !== prevTotpCode) {121 setTotpHot(true);122 window.setTimeout(() => setTotpHot(false), 3000);123 }124 prevTotpCode = state.code;125 });126127 const totpProgress = createMemo(() => {128 const state = totpState();129 if (!state) return 0;130 return ((state.remaining - 1) / 29) * 100;131 });132133 createEffect(() => {134 const shell = document.getElementById("hd-shell");135 if (!shell) return;136137 const state = totpState();138 if (!state) {139 delete shell.dataset.totpActive;140 shell.style.removeProperty("--peach-totp-progress");141 return;142 }143144 shell.dataset.totpActive = "true";145 shell.style.setProperty(146 "--peach-totp-progress",147 `${Math.max(0, totpProgress())}%`148 );149150 onCleanup(() => {151 delete shell.dataset.totpActive;152 shell.style.removeProperty("--peach-totp-progress");153 });154 });155156 const titleSizeClass = createMemo(() => {157 const length = props.entry?.title.length ?? 0;158 if (length >= 28) return "peach-entry-name--xxl";159 if (length >= 20) return "peach-entry-name--xl";160 if (length >= 14) return "peach-entry-name--lg";161 return "";162 });163 const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);164165 166167 function startEdit(field: EditableField, value: string) {168 setEditingField(field);169 setDraft(value);170 setShowGenerator(false);171 setConfirmDelete(false);172 if (field === "url") {173 setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);174 }175 }176177 178 179 180 function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {181 const c = candidate.trim();182 if (!c) return true;183 if (primary.trim() === c) return true;184 return others.some((u) => u.trim() === c);185 }186187 function addCurrentSite(url: string) {188 if (!url) return;189 if (!draft().trim()) {190 setDraft(url);191 return;192 }193 if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;194 setAdditionalUrlsDraft((arr) => [...arr, url]);195 }196197 async function saveField() {198 const field = editingField();199 const entry = props.entry;200 if (!field || !entry) return;201 202 let value = field === "password" ? draft() : draft().trim();203 if (field === "totp" && value.startsWith("otpauth://")) {204 const parsed = parseOtpauthURI(value);205 value = parsed?.secret ?? value;206 }207 if (field === "password" && entry.type !== "note" && !value) {208 return;209 }210 setSaving(true);211 try {212 const patch: Partial<VaultEntry> = { [field]: value };213 if (field === "url") {214 215 patch.additionalUrls = additionalUrlsDraft();216 }217 await sendMessage({218 type: "UPDATE_ENTRY",219 payload: { id: entry.id, patch },220 });221 triggerRing();222 setEditingField(null);223 setShowGenerator(false);224 await props.onFieldSaved();225 } catch {226 227 } finally {228 setSaving(false);229 }230 }231232 function startEditTags() {233 setTagsDraft([...(props.entry?.tags ?? [])]);234 setTagInput("");235 setEditingTags(true);236 }237238 function addTag(value: string) {239 const n = value.toLowerCase().trim();240 if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;241 setTagsDraft([...tagsDraft(), n]);242 setTagInput("");243 }244245 async function saveTags() {246 if (tagInput()) addTag(tagInput());247 const entry = props.entry;248 if (!entry) return;249 const next = tagsDraft().length > 0 ? tagsDraft() : undefined;250 setEditingTags(false);251 setSaving(true);252 try {253 await sendMessage({254 type: "UPDATE_ENTRY",255 payload: { id: entry.id, patch: { tags: next } },256 });257 triggerRing();258 await props.onFieldSaved();259 } catch {260 261 } finally {262 setSaving(false);263 }264 }265266 function cancelEdit() {267 setEditingField(null);268 setDraft("");269 setShowGenerator(false);270 }271272 273 274 275 276 277 278 279 280 281 async function addOtherIdentity(target: "username" | "email") {282 const entry = props.entry;283 if (!entry) return;284 const current = editingField();285 const draftValue = draft().trim();286 const savedUsername = entry.username ?? "";287 const savedEmail = entry.email ?? "";288289 if (current === "username" && target === "username") {290 291 292 if (savedUsername.includes("@") && !entry.email) {293 setSaving(true);294 try {295 await sendMessage({296 type: "UPDATE_ENTRY",297 payload: { id: entry.id, patch: { email: savedUsername, username: "" } },298 });299 await props.onFieldSaved();300 } finally {301 setSaving(false);302 }303 }304 startEdit("username", "");305 return;306 }307308 if (current === "username" && target === "email") {309 if (draftValue && draftValue !== savedUsername) {310 setSaving(true);311 try {312 await sendMessage({313 type: "UPDATE_ENTRY",314 payload: { id: entry.id, patch: { username: draftValue } },315 });316 await props.onFieldSaved();317 } finally {318 setSaving(false);319 }320 }321 startEdit("email", "");322 return;323 }324325 if (current === "email" && target === "username") {326 if (draftValue && draftValue !== savedEmail) {327 setSaving(true);328 try {329 await sendMessage({330 type: "UPDATE_ENTRY",331 payload: { id: entry.id, patch: { email: draftValue } },332 });333 await props.onFieldSaved();334 } finally {335 setSaving(false);336 }337 }338 startEdit("username", "");339 return;340 }341 }342343 function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {344 switch (field) {345 case "title":346 return entry.title;347 case "url":348 return entry.url ?? "";349 case "username":350 return entry.username ?? "";351 case "email":352 return entry.email ?? "";353 case "password":354 return entry.password ?? "";355 case "notes":356 return entry.notes ?? "";357 case "totp":358 return entry.totp ?? "";359 }360 }361362 function handleEditorFocusOut(363 field: Exclude<EditableField, null>,364 event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }365 ) {366 const nextTarget = event.relatedTarget;367 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;368369 370 371 372 if (editingField() !== field) return;373374 const entry = props.entry;375 if (!entry) return;376377 const currentValue = fieldValue(entry, field);378 const nextValue = field === "password" ? draft() : draft().trim();379 if (nextValue === currentValue) {380 cancelEdit();381 }382 }383384 async function savePassword(password: string) {385 const entry = props.entry;386 if (!entry) return;387 setSaving(true);388 try {389 await sendMessage({390 type: "UPDATE_ENTRY",391 payload: { id: entry.id, patch: { password } },392 });393 triggerRing();394 setShowGenerator(false);395 await props.onFieldSaved();396 } catch {397 398 } finally {399 setSaving(false);400 }401 }402403 404405 async function copyField(text: string, label: string, button?: HTMLButtonElement) {406 const ok = await popupCopy(text, label);407 if (ok) {408 const entry = props.entry;409 if (entry?.canary?.webhookUrl) {410 const trigger =411 label === "password"412 ? "password_copied"413 : label === "username"414 ? "username_copied"415 : undefined;416 if (trigger) {417 void sendMessage({418 type: "FIRE_CANARY_PING",419 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },420 });421 }422 }423 triggerRing();424 triggerPipPulse();425 setCopied(null);426 requestAnimationFrame(() => setCopied(label));427 if (button) {428 button.classList.remove("is-copied", "is-retreat");429 void button.offsetWidth;430 const drain = document.createElement("span");431 drain.className = "peach-copy-drain";432 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";433 drain.appendChild(bar);434 button.appendChild(drain);435 window.setTimeout(() => {436 button.classList.remove("is-copied");437 button.classList.add("is-retreat");438 drain.remove();439 window.setTimeout(() => button.classList.remove("is-retreat"), 160);440 }, 1800);441 }442 window.setTimeout(() => setCopied(null), 1800);443 }444 }445446 447448 createEffect(() => {449 if (!props.entry) return;450 const handler = (e: KeyboardEvent) => {451 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;452 if (inInput) return;453 switch (e.key) {454 case "c": e.preventDefault();455 if (props.entry?.password) void copyField(props.entry.password, "password"); break;456 case "u": e.preventDefault();457 if (props.entry?.username) void copyField(props.entry.username, "username"); break;458 case "Escape": e.preventDefault(); props.onEscape?.(); break;459 }460 };461 window.addEventListener("keydown", handler);462 onCleanup(() => window.removeEventListener("keydown", handler));463 });464465 466467 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {468 requestAnimationFrame(() => { el.focus(); el.select?.(); });469 }470471 function onEditKeyDown(e: KeyboardEvent) {472 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {473 e.preventDefault();474 void saveField();475 }476 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }477 }478479 return (480 <>481 <div482 class="peach-ghost"483 aria-hidden="true"484 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;"485 >486 {props.entry?.title || "empty"}487 </div>488489 <div class="peach-stage" style="position:relative;">490 <Show when={props.entry} fallback={<EmptyVaultState />}>491 {(entry) => (492 <>493 {}494 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>495 <div class="peach-breach-badge">496 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">497 <path d="M6 1L11 10H1L6 1z" />498 <line x1="6" y1="5" x2="6" y2="7.5" />499 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />500 </svg>501 <span style="flex:1">found in a known data breach</span>502 <button503 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"504 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}505 >506 Change password507 </button>508 </div>509 </Show>510511 {}512 <div class="peach-entry-header">513 <div class="peach-entry-header-name">514 <Show515 when={editingField() === "title"}516 fallback={517 <button518 class="peach-entry-name-button"519 title="Click to edit title"520 onClick={() => startEdit("title", entry().title)}521 >522 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>523 <span class="peach-entry-name-text">524 <Show525 when={useAnimatedTitle()}526 fallback={entry().title}527 >528 <For each={entry().title.split("")}>529 {(ch, i) => (530 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>531 {ch === " " ? "\u00A0" : ch}532 </span>533 )}534 </For>535 </Show>536 </span>537 </h1>538 </button>539 }540 >541 <div542 class="peach-inline-title-wrap"543 onFocusOut={(event) => handleEditorFocusOut("title", event)}544 >545 <input546 class="peach-inline-title-input"547 value={draft()}548 onInput={(e) => setDraft(e.currentTarget.value)}549 onKeyDown={onEditKeyDown}550 ref={autoFocus}551 disabled={saving()}552 />553 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />554 </div>555 </Show>556 </div>557558 {}559 <Show when={editingField() !== "title"}>560 <Show561 when={confirmDelete()}562 fallback={563 <button564 class="peach-entry-trash-btn"565 onClick={() => setConfirmDelete(true)}566 aria-label="Move to trash"567 title="Delete entry"568 >569 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">570 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />571 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />572 </svg>573 </button>574 }575 >576 <div class="peach-entry-trash-confirm">577 <span class="peach-entry-trash-confirm-label">trash?</span>578 <button579 class="peach-btn peach-btn--sm peach-btn--danger"580 style="font-size:10px;padding:2px 8px;min-height:22px;"581 onClick={() => { setConfirmDelete(false); props.onDelete(); }}582 >583 yes584 </button>585 <button586 class="peach-btn peach-btn--ghost peach-btn--sm"587 style="font-size:10px;padding:2px 8px;min-height:22px;"588 onClick={() => setConfirmDelete(false)}589 >590 no591 </button>592 </div>593 </Show>594 </Show>595 </div>596597 {}598 <Show when={entry().type !== "note" && entry().type !== "card"}>599 <div class="peach-domain" style="position:relative;z-index:1;">600 <Show601 when={editingField() === "url"}602 fallback={603 <div604 class="peach-domain-row peach-inline-clickable"605 title="Click to edit URL"606 onClick={() => startEdit("url", entry().url ?? "")}607 >608 <Show when={faviconSrc()}>609 <img610 src={faviconSrc()!}611 width="16"612 height="16"613 alt=""614 style="border-radius:3px;margin-right:5px;flex-shrink:0;"615 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}616 />617 </Show>618 <span class="peach-domain-value">619 {displayDomain() || (entry().url ? entry().url : "add a website url")}620 </span>621 <span class="peach-inline-edit-hint">edit</span>622 </div>623 }624 >625 <div626 class="peach-inline-field-wrap peach-inline-field-wrap--url"627 onFocusOut={(event) => handleEditorFocusOut("url", event)}628 >629 <div class="peach-inline-field-row">630 <input631 class="peach-inline-input"632 type="url"633 placeholder="https://"634 value={draft()}635 onInput={(e) => setDraft(e.currentTarget.value)}636 onKeyDown={onEditKeyDown}637 ref={autoFocus}638 disabled={saving()}639 />640 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />641 </div>642 <div class="peach-additional-urls">643 <Show when={additionalUrlsDraft().length > 0}>644 <span class="peach-label peach-additional-urls-label">also matches</span>645 </Show>646 <For each={additionalUrlsDraft()}>647 {(value, i) => (648 <div class="peach-additional-url-row">649 <input650 class="peach-inline-input"651 type="url"652 placeholder="https://"653 value={value}654 onInput={(e) => setAdditionalUrlsDraft((arr) => {655 const next = [...arr];656 next[i()] = e.currentTarget.value;657 return next;658 })}659 disabled={saving()}660 />661 <button662 class="peach-additional-url-remove"663 type="button"664 aria-label="Remove URL"665 onMouseDown={(e) => e.preventDefault()}666 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}667 disabled={saving()}668 >×</button>669 </div>670 )}671 </For>672 <button673 class="peach-add-field-link peach-additional-urls-add"674 type="button"675 onMouseDown={(e) => e.preventDefault()}676 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}677 disabled={saving()}678 >679 + add another url680 </button>681 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>682 <button683 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"684 type="button"685 title={props.currentTabUrl}686 onMouseDown={(e) => e.preventDefault()}687 onClick={() => addCurrentSite(props.currentTabUrl!)}688 disabled={saving()}689 >690 + add current site691 </button>692 </Show>693 </div>694 </div>695 </Show>696 </div>697 </Show>698699 {}700 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>701 <div702 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"703 onClick={startEditTags}704 title="Click to edit tags"705 >706 <For each={entry().tags!}>707 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}708 </For>709 </div>710 </Show>711712 {}713 <Show when={entry().type === "note"}>714 <div class="peach-entry-note-toolbar-row">715 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />716 </div>717 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">718 <NoteEditorInline719 entryKey={entry().id}720 initialContent={entry().notes ?? ""}721 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}722 onBlur={saveNoteInline}723 autoFocus={false}724 />725 </section>726 </Show>727728 {}729 <Show when={entry().type === "card"}>730 <section class="peach-fields" style="position:relative;z-index:1;">731 {}732 <Show when={entry().last4}>733 <div class="peach-field-card" style="padding-bottom:4px;">734 <span class="peach-label">735 <Show when={entry().network && entry().network !== "unknown"}>736 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>737 </Show>738 {entry().cardNickname || `•••• ${entry().last4}`}739 </span>740 </div>741 </Show>742743 {}744 <div class="peach-field-card">745 <span class="peach-label">cardholder name</span>746 <div class="peach-field-value-row">747 <span class="peach-field-value">{entry().cardholderName || "—"}</span>748 <Show when={entry().cardholderName}>749 <button750 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}751 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}752 aria-label="Copy cardholder name"753 >754 <CopyGlyph /><span class="peach-copy-sheen" />755 </button>756 </Show>757 </div>758 </div>759760 {}761 <div class="peach-field-card">762 <span class="peach-label">card number</span>763 <div class="peach-field-value-row">764 <span class={`peach-field-value peach-card-number`}>765 <Show when={showCardNumber()} fallback={766 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>767 }>768 <span>{entry().cardNumber ?? ""}</span>769 </Show>770 </span>771 <div class="peach-field-actions">772 <button773 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"774 onClick={() => setShowCardNumber(!showCardNumber())}775 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}776 >777 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>778 <EyeClosedGlyph />779 </Show>780 </button>781 <Show when={entry().cardNumber}>782 <button783 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}784 onClick={(e) => {785 const cardNum = entry().cardNumber!;786 if (entry().canary?.webhookUrl) {787 void sendMessage({788 type: "FIRE_CANARY_PING",789 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },790 });791 }792 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);793 }}794 aria-label="Copy card number"795 >796 <CopyGlyph /><span class="peach-copy-sheen" />797 </button>798 </Show>799 </div>800 </div>801 </div>802803 {}804 <Show when={entry().expMonth || entry().expYear}>805 <div class="peach-field-card">806 <span class="peach-label">expiry</span>807 <div class="peach-field-value-row">808 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>809 <button810 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}811 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}812 aria-label="Copy expiry"813 >814 <CopyGlyph /><span class="peach-copy-sheen" />815 </button>816 </div>817 </div>818 </Show>819820 {}821 <Show when={entry().cvc}>822 <div class="peach-field-card">823 <span class="peach-label">CVC</span>824 <div class="peach-field-value-row">825 <span class="peach-field-value">826 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>827 <span>{entry().cvc}</span>828 </Show>829 </span>830 <div class="peach-field-actions">831 <button832 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"833 onClick={() => setShowCardCvc(!showCardCvc())}834 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}835 >836 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>837 <EyeClosedGlyph />838 </Show>839 </button>840 <button841 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}842 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}843 aria-label="Copy CVC"844 >845 <CopyGlyph /><span class="peach-copy-sheen" />846 </button>847 </div>848 </div>849 </div>850 </Show>851 </section>852 </Show>853854 {}855 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>856 <section class="peach-fields" style="position:relative;z-index:1;">857858 {859860}861 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>862 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>863 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>864 <Show865 when={editingField() === "username"}866 fallback={867 <div class="peach-field-value-row">868 <span869 class="peach-field-value peach-inline-clickable"870 title="Click to edit"871 onClick={() => startEdit("username", entry().username ?? "")}872 >873 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}874 </span>875 <Show when={entry().username}>876 <button877 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}878 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}879 aria-label="Copy username"880 >881 <CopyGlyph /><span class="peach-copy-sheen" />882 </button>883 </Show>884 </div>885 }886 >887 <div888 class="peach-inline-field-wrap"889 onFocusOut={(event) => handleEditorFocusOut("username", event)}890 >891 <input892 class="peach-inline-input"893 value={draft()}894 onInput={(e) => setDraft(e.currentTarget.value)}895 onKeyDown={onEditKeyDown}896 ref={autoFocus}897 disabled={saving()}898 autocomplete="off"899 />900 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />901 {902903904}905 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>906 <button907 class="peach-add-field-btn"908 type="button"909 onMouseDown={(e) => e.preventDefault()}910 onClick={() => {911 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);912 const hasSeparateEmail = !!entry().email;913 if (isEmailShape && !hasSeparateEmail) {914 void addOtherIdentity("username");915 } else {916 void addOtherIdentity("email");917 }918 }}919 >920 {(() => {921 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);922 const hasSeparateEmail = !!entry().email;923 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";924 })()}925 </button>926 </Show>927 </div>928 </Show>929 </div>930 </Show>931932 {933}934 <Show when={entry().email || editingField() === "email"}>935 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>936 <span class="peach-label">email</span>937 <Show938 when={editingField() === "email"}939 fallback={940 <div class="peach-field-value-row">941 <span942 class="peach-field-value peach-inline-clickable"943 title="Click to edit"944 onClick={() => startEdit("email", entry().email ?? "")}945 >946 {entry().email}947 </span>948 <Show when={entry().email}>949 <button950 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}951 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}952 aria-label="Copy email"953 >954 <CopyGlyph /><span class="peach-copy-sheen" />955 </button>956 </Show>957 </div>958 }959 >960 <div961 class="peach-inline-field-wrap"962 onFocusOut={(event) => handleEditorFocusOut("email", event)}963 >964 <input965 class="peach-inline-input"966 value={draft()}967 onInput={(e) => setDraft(e.currentTarget.value)}968 onKeyDown={onEditKeyDown}969 ref={autoFocus}970 disabled={saving()}971 autocomplete="email"972 type="email"973 />974 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />975 <Show when={!entry().username || entry().username?.includes("@")}>976 <button977 class="peach-add-field-btn"978 type="button"979 onMouseDown={(e) => e.preventDefault()}980 onClick={() => void addOtherIdentity("username")}981 >982 + add username983 </button>984 </Show>985 </div>986 </Show>987 </div>988 </Show>989990 {}991 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>992 <span class="peach-label">password</span>993 <Show994 when={editingField() === "password"}995 fallback={996 <div class="peach-field-value-row">997 <span998 class="peach-password-value"999 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1000 style="cursor:pointer;"1001 title="Click to edit password"1002 onClick={() => startEdit("password", entry().password ?? "")}1003 >1004 <Show1005 when={showPassword()}1006 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1007 >1008 <span class="peach-password-text">{entry().password ?? ""}</span>1009 </Show>1010 </span>1011 <div class="peach-field-actions">1012 <button1013 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1014 onClick={() => setShowPassword(!showPassword())}1015 aria-label="Reveal password"1016 >1017 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1018 <EyeClosedGlyph />1019 </Show>1020 </button>1021 <button1022 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1023 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1024 aria-label="Copy password"1025 >1026 <CopyGlyph /><span class="peach-copy-sheen" />1027 </button>1028 </div>1029 </div>1030 }1031 >1032 {}1033 <div1034 class="peach-inline-field-wrap"1035 onFocusOut={(event) => handleEditorFocusOut("password", event)}1036 >1037 <div class="peach-password-edit-row">1038 <input1039 class="peach-inline-input"1040 type={showPassword() ? "text" : "password"}1041 placeholder="paste or type a password"1042 value={draft()}1043 onInput={(e) => setDraft(e.currentTarget.value)}1044 onKeyDown={(e) => {1045 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1046 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1047 }}1048 ref={autoFocus}1049 disabled={saving()}1050 autocomplete="new-password"1051 />1052 <button1053 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1054 type="button"1055 onClick={() => setShowPassword(!showPassword())}1056 aria-label={showPassword() ? "Hide" : "Show"}1057 >1058 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1059 <EyeClosedGlyph />1060 </Show>1061 </button>1062 </div>1063 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1064 <button1065 class="peach-generate-toggle-link"1066 type="button"1067 onClick={() => setShowGenerator(!showGenerator())}1068 >1069 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1070 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1071 <path d="M9.5 2.5H11V1" />1072 </svg>1073 {showGenerator() ? "hide generator" : "generate"}1074 </button>1075 <Show when={showGenerator()}>1076 <GeneratorDrawer1077 open={true}1078 compact={true}1079 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1080 />1081 </Show>1082 </div>1083 </Show>1084 </div>10851086 </section>1087 </Show>10881089 {}1090 <Show when={entry().type !== "note" && entry().type !== "card"}>1091 <Show1092 when={entry().notes || editingField() === "notes"}1093 fallback={1094 <button1095 class="peach-add-field-link peach-entry-add-link"1096 onClick={() => startEdit("notes", "")}1097 >1098 + notes1099 </button>1100 }1101 >1102 <div style="position:relative;z-index:1;margin-top:6px;">1103 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1104 <Show1105 when={editingField() === "notes"}1106 fallback={1107 <div1108 class="peach-note-content peach-inline-clickable"1109 style="cursor:text;white-space:pre-wrap;"1110 onClick={() => startEdit("notes", entry().notes ?? "")}1111 >1112 {entry().notes}1113 </div>1114 }1115 >1116 <div1117 class="peach-inline-field-wrap"1118 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1119 >1120 <textarea1121 class="peach-inline-textarea"1122 value={draft()}1123 onInput={(e) => setDraft(e.currentTarget.value)}1124 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1125 ref={(el) => requestAnimationFrame(() => el?.focus())}1126 rows={4}1127 disabled={saving()}1128 />1129 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1130 </div>1131 </Show>1132 </div>1133 </Show>1134 </Show>11351136 {}1137 <Show1138 when={editingTags()}1139 fallback={1140 <Show when={!entry().tags || entry().tags!.length === 0}>1141 <button1142 class="peach-add-field-link peach-entry-add-link"1143 onClick={startEditTags}1144 >1145 + tags1146 </button>1147 </Show>1148 }1149 >1150 <div1151 class="peach-inline-tag-editor"1152 onFocusOut={(e) => {1153 const next = e.relatedTarget;1154 if (next instanceof Node && e.currentTarget.contains(next)) return;1155 void saveTags();1156 }}1157 >1158 <span class="peach-label">tags</span>1159 <div class="peach-tag-input-wrap">1160 <For each={tagsDraft()}>1161 {(tag) => (1162 <span class="peach-tag-chip">1163 {tag}1164 <button1165 class="peach-tag-chip-remove"1166 onMouseDown={(e) => e.preventDefault()}1167 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1168 aria-label={`Remove ${tag}`}1169 >×</button>1170 </span>1171 )}1172 </For>1173 <input1174 class="peach-tag-input"1175 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1176 value={tagInput()}1177 onInput={(e) => setTagInput(e.currentTarget.value)}1178 onKeyDown={(e) => {1179 if (e.key === "Enter" || e.key === ",") {1180 e.preventDefault();1181 addTag(tagInput());1182 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1183 setTagsDraft(tagsDraft().slice(0, -1));1184 } else if (e.key === "Escape") {1185 e.preventDefault();1186 setEditingTags(false);1187 setTagInput("");1188 }1189 }}1190 ref={(el) => requestAnimationFrame(() => el?.focus())}1191 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1192 />1193 </div>1194 </div>1195 </Show>11961197 {}1198 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1199 <Show1200 when={totpState() || editingField() === "totp"}1201 fallback={1202 <button1203 class="peach-add-field-link peach-entry-add-link"1204 onClick={() => startEdit("totp", "")}1205 >1206 + 2FA1207 </button>1208 }1209 >1210 <section class="peach-totp-band">1211 <Show1212 when={editingField() === "totp"}1213 fallback={1214 <Show when={totpState()}>1215 {(state) => (1216 <>1217 <div1218 class="peach-totp-main"1219 onClick={() => {1220 1221 1222 if (totpClickTimer) return;1223 totpClickTimer = setTimeout(() => {1224 totpClickTimer = null;1225 void copyField(state().code.replace(" ", ""), "totp");1226 }, 220);1227 }}1228 onDblClick={() => {1229 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1230 startEdit("totp", entry().totp ?? "");1231 }}1232 title="Click to copy · double-click to edit"1233 >1234 <span class="peach-label">2-factor</span>1235 <div class="peach-totp-code-row">1236 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1237 <span class="peach-totp-code-text">1238 {state().code.slice(0, 3)} {state().code.slice(3)}1239 </span>1240 </p>1241 </div>1242 </div>1243 <div class="peach-totp-meta">1244 <span class="peach-label">expires</span>1245 <div class="peach-totp-countdown">{state().remaining}s</div>1246 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1247 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1248 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1249 stroke-dasharray="106.8"1250 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1251 />1252 </svg>1253 </div>1254 </>1255 )}1256 </Show>1257 }1258 >1259 <div1260 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1261 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1262 >1263 <span class="peach-label">2-factor</span>1264 <input1265 class="peach-inline-input"1266 value={draft()}1267 onInput={(e) => setDraft(e.currentTarget.value)}1268 onKeyDown={onEditKeyDown}1269 ref={autoFocus}1270 disabled={saving()}1271 placeholder="otpauth://... or base32 secret"1272 autocomplete="off"1273 />1274 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1275 </div>1276 </Show>1277 </section>1278 </Show>1279 </Show>12801281 {}1282 <Show when={entry().type === "login"}>1283 <button1284 class="peach-add-field-link peach-entry-add-link"1285 onClick={() => setShowAdvanced((v) => !v)}1286 aria-expanded={showAdvanced()}1287 >1288 {showAdvanced() ? "− Advanced" : "+ Advanced"}1289 </button>1290 <Show when={showAdvanced()}>1291 <Show when={!isPasskeyOnly(entry())}>1292 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1293 </Show>12941295 <Show when={entry().passkey}>1296 {(passkey) => {1297 const currentBrowser = detectBrowser();1298 const crossBrowser =1299 passkey().browser &&1300 currentBrowser !== "unknown" &&1301 passkey().browser !== currentBrowser;1302 const algorithmLabel =1303 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1304 const formatDate = (ts?: number) =>1305 ts ? new Date(ts).toLocaleDateString() : "Never";1306 return (1307 <section1308 class="peach-fields peach-passkey-section"1309 style="position:relative;z-index:1;margin-top:6px;"1310 >1311 <div class="peach-field-card">1312 <span class="peach-label">passkey · {passkey().rpId}</span>1313 <div class="peach-field-value-row">1314 <span class="peach-field-value">{passkey().userName}</span>1315 <span class="peach-passkey-badge">passkey</span>1316 </div>1317 </div>13181319 <div class="peach-field-card">1320 <span class="peach-label">algorithm</span>1321 <div class="peach-field-value-row">1322 <span class="peach-field-value">{algorithmLabel}</span>1323 </div>1324 </div>13251326 <div class="peach-field-card">1327 <span class="peach-label">activity</span>1328 <div class="peach-field-value-row peach-passkey-activity">1329 <span class="peach-field-value">1330 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1331 </span>1332 <span class="peach-passkey-meta">1333 created {formatDate(passkey().createdAt)}1334 </span>1335 <span class="peach-passkey-meta">1336 last used {formatDate(passkey().lastUsedAt)}1337 </span>1338 </div>1339 </div>13401341 <Show when={crossBrowser}>1342 <div class="peach-info-notice peach-info-notice--warn">1343 {`This passkey was created on ${1344 passkey().browser === "chrome" ? "Chrome" : "Firefox"1345 }. To use it on ${1346 currentBrowser === "chrome" ? "Chrome" : "Firefox"1347 }, re-register it on this device.`}1348 </div>1349 </Show>13501351 <div class="peach-info-notice">1352 {passkey().browser === "firefox"1353 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1354 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1355 </div>13561357 <button1358 class="peach-add-field-link peach-entry-add-link"1359 style="color:rgba(220,38,38,0.7);"1360 onClick={async () => {1361 const id = entry().id;1362 await sendMessage({1363 type: "UPDATE_ENTRY",1364 payload: { id, patch: { passkey: undefined } },1365 });1366 await props.onFieldSaved();1367 }}1368 >1369 − remove passkey1370 </button>1371 </section>1372 );1373 }}1374 </Show>1375 </Show>1376 </Show>13771378 {}1379 <div style="height:4px;" />1380 </>1381 )}1382 </Show>1383 </div>13841385 </>1386 );1387}1388138913901391function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1392 return (1393 <div class="peach-inline-actions">1394 <button1395 class="peach-inline-save-btn"1396 onClick={props.onSave}1397 disabled={props.saving}1398 aria-label="Save"1399 title="Save (Enter)"1400 >1401 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1402 <path d="M1 4.5L4 7.5L10 1.5" />1403 </svg>1404 </button>1405 <button1406 class="peach-inline-cancel-btn"1407 onClick={props.onCancel}1408 disabled={props.saving}1409 aria-label="Cancel"1410 title="Cancel (Esc)"1411 >1412 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1413 <path d="M1 1L8 8M8 1L1 8" />1414 </svg>1415 </button>1416 </div>1417 );1418}14191420function EmptyVaultState() {1421 return (1422 <>1423 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1424 <div class="peach-entry-name">1425 <span class="peach-entry-name-text">1426 <span class="peach-entry-name-char" style="--char-index:0">e</span>1427 <span class="peach-entry-name-char" style="--char-index:1">m</span>1428 <span class="peach-entry-name-char" style="--char-index:2">p</span>1429 <span class="peach-entry-name-char" style="--char-index:3">t</span>1430 <span class="peach-entry-name-char" style="--char-index:4">y</span>1431 </span>1432 </div>1433 </div>1434 <div class="peach-empty-copy">1435 <span class="peach-label">vault</span>1436 <p>Your vault is ready for its first entry.</p>1437 </div>1438 </>1439 );1440}14411442function CopyGlyph() {1443 return (1444 <span class="peach-copy-icon">1445 <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">1446 <rect x="4" y="4" width="8" height="9" rx="1.5" />1447 <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" />1448 </svg>1449 </span>1450 );1451}14521453function EyeClosedGlyph() {1454 return (1455 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1456 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1457 <path d="M3.5 5.5L2.5 7.5" />1458 <path d="M7 6.5V8.5" />1459 <path d="M10.5 5.5L11.5 7.5" />1460 </svg>1461 );1462}14631464function EyeOpenGlyph() {1465 return (1466 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1467 <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" />1468 <circle cx="7" cy="5" r="1.75" />1469 </svg>1470 );1471}14721473function ChevronGlyph(props: { open: boolean }) {1474 return (1475 <svg1476 width="8"1477 height="8"1478 viewBox="0 0 8 8"1479 fill="none"1480 stroke="currentColor"1481 stroke-width="1.3"1482 stroke-linecap="round"1483 stroke-linejoin="round"1484 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1485 >1486 <path d="M2 1L6 4L2 7" />1487 </svg>1488 );1489}14901491function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1492 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1493 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1494 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1495 const [testResult, setTestResult] = createSignal<string | null>(null);1496 const [testing, setTesting] = createSignal(false);14971498 async function save() {1499 const url = webhookUrl().trim();1500 const nextLabel = label().trim() || undefined;1501 const canary = enabled() && url1502 ? {1503 webhookUrl: url,1504 label: nextLabel,1505 lastPinged: props.entry.canary?.lastPinged,1506 pingCount: props.entry.canary?.pingCount,1507 }1508 : undefined;1509 await sendMessage({1510 type: "UPDATE_ENTRY",1511 payload: { id: props.entry.id, patch: { canary } },1512 });1513 await props.onSaved();1514 }15151516 async function testPing() {1517 const url = webhookUrl().trim();1518 if (!url) return;1519 setTesting(true);1520 setTestResult(null);1521 const res = await sendMessage({1522 type: "FIRE_CANARY_PING",1523 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1524 }) as { ok: boolean; error?: string };1525 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1526 setTesting(false);1527 }15281529 return (1530 <div class="peach-canary-section">1531 <div class="peach-field-value-row">1532 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1533 <button1534 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1535 onClick={() => { setEnabled(!enabled()); void save(); }}1536 aria-label="Toggle canary"1537 >1538 <span class="peach-toggle-knob" />1539 </button>1540 </div>1541 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1542 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1543 </p>1544 <Show when={enabled()}>1545 <div class="peach-inline-field-wrap" style="margin-top:8px;">1546 <input1547 class="peach-inline-input"1548 placeholder="https://hooks.zapier.com/..."1549 value={webhookUrl()}1550 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1551 onBlur={() => void save()}1552 />1553 <input1554 class="peach-inline-input"1555 placeholder="label (optional, e.g. AWS decoy)"1556 value={label()}1557 onInput={(e) => setLabel(e.currentTarget.value)}1558 onBlur={() => void save()}1559 />1560 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1561 <button1562 class="peach-btn peach-btn--ghost peach-btn--sm"1563 onClick={() => void testPing()}1564 disabled={testing() || !webhookUrl().trim()}1565 >1566 {testing() ? "sending..." : "test ping"}1567 </button>1568 <Show when={testResult()}>1569 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1570 </Show>1571 </div>1572 <Show when={props.entry.canary?.lastPinged}>1573 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1574 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1575 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1576 </p>1577 </Show>1578 </div>1579 </Show>1580 </div>1581 );1582}