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 const entry = props.entry;370 if (!entry) return;371372 const currentValue = fieldValue(entry, field);373 const nextValue = field === "password" ? draft() : draft().trim();374 if (nextValue === currentValue) {375 cancelEdit();376 }377 }378379 async function savePassword(password: string) {380 const entry = props.entry;381 if (!entry) return;382 setSaving(true);383 try {384 await sendMessage({385 type: "UPDATE_ENTRY",386 payload: { id: entry.id, patch: { password } },387 });388 triggerRing();389 setShowGenerator(false);390 await props.onFieldSaved();391 } catch {392 393 } finally {394 setSaving(false);395 }396 }397398 399400 async function copyField(text: string, label: string, button?: HTMLButtonElement) {401 const ok = await popupCopy(text, label);402 if (ok) {403 const entry = props.entry;404 if (entry?.canary?.webhookUrl) {405 const trigger =406 label === "password"407 ? "password_copied"408 : label === "username"409 ? "username_copied"410 : undefined;411 if (trigger) {412 void sendMessage({413 type: "FIRE_CANARY_PING",414 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },415 });416 }417 }418 triggerRing();419 triggerPipPulse();420 setCopied(null);421 requestAnimationFrame(() => setCopied(label));422 if (button) {423 button.classList.remove("is-copied", "is-retreat");424 void button.offsetWidth;425 const drain = document.createElement("span");426 drain.className = "peach-copy-drain";427 const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";428 drain.appendChild(bar);429 button.appendChild(drain);430 window.setTimeout(() => {431 button.classList.remove("is-copied");432 button.classList.add("is-retreat");433 drain.remove();434 window.setTimeout(() => button.classList.remove("is-retreat"), 160);435 }, 1800);436 }437 window.setTimeout(() => setCopied(null), 1800);438 }439 }440441 442443 createEffect(() => {444 if (!props.entry) return;445 const handler = (e: KeyboardEvent) => {446 const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;447 if (inInput) return;448 switch (e.key) {449 case "c": e.preventDefault();450 if (props.entry?.password) void copyField(props.entry.password, "password"); break;451 case "u": e.preventDefault();452 if (props.entry?.username) void copyField(props.entry.username, "username"); break;453 case "Escape": e.preventDefault(); props.onEscape?.(); break;454 }455 };456 window.addEventListener("keydown", handler);457 onCleanup(() => window.removeEventListener("keydown", handler));458 });459460 461462 function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {463 requestAnimationFrame(() => { el.focus(); el.select?.(); });464 }465466 function onEditKeyDown(e: KeyboardEvent) {467 if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {468 e.preventDefault();469 void saveField();470 }471 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }472 }473474 return (475 <>476 <div477 class="peach-ghost"478 aria-hidden="true"479 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;"480 >481 {props.entry?.title || "empty"}482 </div>483484 <div class="peach-stage" style="position:relative;">485 <Show when={props.entry} fallback={<EmptyVaultState />}>486 {(entry) => (487 <>488 {}489 <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>490 <div class="peach-breach-badge">491 <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">492 <path d="M6 1L11 10H1L6 1z" />493 <line x1="6" y1="5" x2="6" y2="7.5" />494 <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />495 </svg>496 <span style="flex:1">found in a known data breach</span>497 <button498 class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"499 onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}500 >501 Change password502 </button>503 </div>504 </Show>505506 {}507 <div class="peach-entry-header">508 <div class="peach-entry-header-name">509 <Show510 when={editingField() === "title"}511 fallback={512 <button513 class="peach-entry-name-button"514 title="Click to edit title"515 onClick={() => startEdit("title", entry().title)}516 >517 <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>518 <span class="peach-entry-name-text">519 <Show520 when={useAnimatedTitle()}521 fallback={entry().title}522 >523 <For each={entry().title.split("")}>524 {(ch, i) => (525 <span class="peach-entry-name-char" style={`--char-index:${i()}`}>526 {ch === " " ? "\u00A0" : ch}527 </span>528 )}529 </For>530 </Show>531 </span>532 </h1>533 </button>534 }535 >536 <div537 class="peach-inline-title-wrap"538 onFocusOut={(event) => handleEditorFocusOut("title", event)}539 >540 <input541 class="peach-inline-title-input"542 value={draft()}543 onInput={(e) => setDraft(e.currentTarget.value)}544 onKeyDown={onEditKeyDown}545 ref={autoFocus}546 disabled={saving()}547 />548 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />549 </div>550 </Show>551 </div>552553 {}554 <Show when={editingField() !== "title"}>555 <Show556 when={confirmDelete()}557 fallback={558 <button559 class="peach-entry-trash-btn"560 onClick={() => setConfirmDelete(true)}561 aria-label="Move to trash"562 title="Delete entry"563 >564 <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">565 <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />566 <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />567 </svg>568 </button>569 }570 >571 <div class="peach-entry-trash-confirm">572 <span class="peach-entry-trash-confirm-label">trash?</span>573 <button574 class="peach-btn peach-btn--sm peach-btn--danger"575 style="font-size:10px;padding:2px 8px;min-height:22px;"576 onClick={() => { setConfirmDelete(false); props.onDelete(); }}577 >578 yes579 </button>580 <button581 class="peach-btn peach-btn--ghost peach-btn--sm"582 style="font-size:10px;padding:2px 8px;min-height:22px;"583 onClick={() => setConfirmDelete(false)}584 >585 no586 </button>587 </div>588 </Show>589 </Show>590 </div>591592 {}593 <Show when={entry().type !== "note" && entry().type !== "card"}>594 <div class="peach-domain" style="position:relative;z-index:1;">595 <Show596 when={editingField() === "url"}597 fallback={598 <div599 class="peach-domain-row peach-inline-clickable"600 title="Click to edit URL"601 onClick={() => startEdit("url", entry().url ?? "")}602 >603 <Show when={faviconSrc()}>604 <img605 src={faviconSrc()!}606 width="16"607 height="16"608 alt=""609 style="border-radius:3px;margin-right:5px;flex-shrink:0;"610 onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}611 />612 </Show>613 <span class="peach-domain-value">614 {displayDomain() || (entry().url ? entry().url : "add a website url")}615 </span>616 <span class="peach-inline-edit-hint">edit</span>617 </div>618 }619 >620 <div621 class="peach-inline-field-wrap peach-inline-field-wrap--url"622 onFocusOut={(event) => handleEditorFocusOut("url", event)}623 >624 <div class="peach-inline-field-row">625 <input626 class="peach-inline-input"627 type="url"628 placeholder="https://"629 value={draft()}630 onInput={(e) => setDraft(e.currentTarget.value)}631 onKeyDown={onEditKeyDown}632 ref={autoFocus}633 disabled={saving()}634 />635 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />636 </div>637 <div class="peach-additional-urls">638 <Show when={additionalUrlsDraft().length > 0}>639 <span class="peach-label peach-additional-urls-label">also matches</span>640 </Show>641 <For each={additionalUrlsDraft()}>642 {(value, i) => (643 <div class="peach-additional-url-row">644 <input645 class="peach-inline-input"646 type="url"647 placeholder="https://"648 value={value}649 onInput={(e) => setAdditionalUrlsDraft((arr) => {650 const next = [...arr];651 next[i()] = e.currentTarget.value;652 return next;653 })}654 disabled={saving()}655 />656 <button657 class="peach-additional-url-remove"658 type="button"659 aria-label="Remove URL"660 onMouseDown={(e) => e.preventDefault()}661 onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}662 disabled={saving()}663 >×</button>664 </div>665 )}666 </For>667 <button668 class="peach-add-field-link peach-additional-urls-add"669 type="button"670 onMouseDown={(e) => e.preventDefault()}671 onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}672 disabled={saving()}673 >674 + add another url675 </button>676 <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>677 <button678 class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"679 type="button"680 title={props.currentTabUrl}681 onMouseDown={(e) => e.preventDefault()}682 onClick={() => addCurrentSite(props.currentTabUrl!)}683 disabled={saving()}684 >685 + add current site686 </button>687 </Show>688 </div>689 </div>690 </Show>691 </div>692 </Show>693694 {}695 <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>696 <div697 style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"698 onClick={startEditTags}699 title="Click to edit tags"700 >701 <For each={entry().tags!}>702 {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}703 </For>704 </div>705 </Show>706707 {}708 <Show when={entry().type === "note"}>709 <div class="peach-entry-note-toolbar-row">710 <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />711 </div>712 <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">713 <NoteEditorInline714 entryKey={entry().id}715 initialContent={entry().notes ?? ""}716 onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}717 onBlur={saveNoteInline}718 autoFocus={false}719 />720 </section>721 </Show>722723 {}724 <Show when={entry().type === "card"}>725 <section class="peach-fields" style="position:relative;z-index:1;">726 {}727 <Show when={entry().last4}>728 <div class="peach-field-card" style="padding-bottom:4px;">729 <span class="peach-label">730 <Show when={entry().network && entry().network !== "unknown"}>731 <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>732 </Show>733 {entry().cardNickname || `•••• ${entry().last4}`}734 </span>735 </div>736 </Show>737738 {}739 <div class="peach-field-card">740 <span class="peach-label">cardholder name</span>741 <div class="peach-field-value-row">742 <span class="peach-field-value">{entry().cardholderName || "—"}</span>743 <Show when={entry().cardholderName}>744 <button745 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}746 onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}747 aria-label="Copy cardholder name"748 >749 <CopyGlyph /><span class="peach-copy-sheen" />750 </button>751 </Show>752 </div>753 </div>754755 {}756 <div class="peach-field-card">757 <span class="peach-label">card number</span>758 <div class="peach-field-value-row">759 <span class={`peach-field-value peach-card-number`}>760 <Show when={showCardNumber()} fallback={761 <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>762 }>763 <span>{entry().cardNumber ?? ""}</span>764 </Show>765 </span>766 <div class="peach-field-actions">767 <button768 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"769 onClick={() => setShowCardNumber(!showCardNumber())}770 aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}771 >772 <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>773 <EyeClosedGlyph />774 </Show>775 </button>776 <Show when={entry().cardNumber}>777 <button778 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}779 onClick={(e) => {780 const cardNum = entry().cardNumber!;781 if (entry().canary?.webhookUrl) {782 void sendMessage({783 type: "FIRE_CANARY_PING",784 payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },785 });786 }787 void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);788 }}789 aria-label="Copy card number"790 >791 <CopyGlyph /><span class="peach-copy-sheen" />792 </button>793 </Show>794 </div>795 </div>796 </div>797798 {}799 <Show when={entry().expMonth || entry().expYear}>800 <div class="peach-field-card">801 <span class="peach-label">expiry</span>802 <div class="peach-field-value-row">803 <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>804 <button805 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}806 onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}807 aria-label="Copy expiry"808 >809 <CopyGlyph /><span class="peach-copy-sheen" />810 </button>811 </div>812 </div>813 </Show>814815 {}816 <Show when={entry().cvc}>817 <div class="peach-field-card">818 <span class="peach-label">CVC</span>819 <div class="peach-field-value-row">820 <span class="peach-field-value">821 <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>822 <span>{entry().cvc}</span>823 </Show>824 </span>825 <div class="peach-field-actions">826 <button827 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"828 onClick={() => setShowCardCvc(!showCardCvc())}829 aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}830 >831 <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>832 <EyeClosedGlyph />833 </Show>834 </button>835 <button836 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}837 onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}838 aria-label="Copy CVC"839 >840 <CopyGlyph /><span class="peach-copy-sheen" />841 </button>842 </div>843 </div>844 </div>845 </Show>846 </section>847 </Show>848849 {}850 <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>851 <section class="peach-fields" style="position:relative;z-index:1;">852853 {854855}856 <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>857 <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>858 <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>859 <Show860 when={editingField() === "username"}861 fallback={862 <div class="peach-field-value-row">863 <span864 class="peach-field-value peach-inline-clickable"865 title="Click to edit"866 onClick={() => startEdit("username", entry().username ?? "")}867 >868 {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}869 </span>870 <Show when={entry().username}>871 <button872 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}873 onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}874 aria-label="Copy username"875 >876 <CopyGlyph /><span class="peach-copy-sheen" />877 </button>878 </Show>879 </div>880 }881 >882 <div883 class="peach-inline-field-wrap"884 onFocusOut={(event) => handleEditorFocusOut("username", event)}885 >886 <input887 class="peach-inline-input"888 value={draft()}889 onInput={(e) => setDraft(e.currentTarget.value)}890 onKeyDown={onEditKeyDown}891 ref={autoFocus}892 disabled={saving()}893 autocomplete="off"894 />895 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />896 {897898899}900 <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>901 <button902 class="peach-add-field-btn"903 type="button"904 onMouseDown={(e) => e.preventDefault()}905 onClick={() => {906 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);907 const hasSeparateEmail = !!entry().email;908 if (isEmailShape && !hasSeparateEmail) {909 void addOtherIdentity("username");910 } else {911 void addOtherIdentity("email");912 }913 }}914 >915 {(() => {916 const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);917 const hasSeparateEmail = !!entry().email;918 return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";919 })()}920 </button>921 </Show>922 </div>923 </Show>924 </div>925 </Show>926927 {928}929 <Show when={entry().email || editingField() === "email"}>930 <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>931 <span class="peach-label">email</span>932 <Show933 when={editingField() === "email"}934 fallback={935 <div class="peach-field-value-row">936 <span937 class="peach-field-value peach-inline-clickable"938 title="Click to edit"939 onClick={() => startEdit("email", entry().email ?? "")}940 >941 {entry().email}942 </span>943 <Show when={entry().email}>944 <button945 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}946 onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}947 aria-label="Copy email"948 >949 <CopyGlyph /><span class="peach-copy-sheen" />950 </button>951 </Show>952 </div>953 }954 >955 <div956 class="peach-inline-field-wrap"957 onFocusOut={(event) => handleEditorFocusOut("email", event)}958 >959 <input960 class="peach-inline-input"961 value={draft()}962 onInput={(e) => setDraft(e.currentTarget.value)}963 onKeyDown={onEditKeyDown}964 ref={autoFocus}965 disabled={saving()}966 autocomplete="email"967 type="email"968 />969 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />970 <Show when={!entry().username || entry().username?.includes("@")}>971 <button972 class="peach-add-field-btn"973 type="button"974 onMouseDown={(e) => e.preventDefault()}975 onClick={() => void addOtherIdentity("username")}976 >977 + add username978 </button>979 </Show>980 </div>981 </Show>982 </div>983 </Show>984985 {}986 <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>987 <span class="peach-label">password</span>988 <Show989 when={editingField() === "password"}990 fallback={991 <div class="peach-field-value-row">992 <span993 class="peach-password-value"994 classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}995 style="cursor:pointer;"996 title="Click to edit password"997 onClick={() => startEdit("password", entry().password ?? "")}998 >999 <Show1000 when={showPassword()}1001 fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1002 >1003 <span class="peach-password-text">{entry().password ?? ""}</span>1004 </Show>1005 </span>1006 <div class="peach-field-actions">1007 <button1008 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1009 onClick={() => setShowPassword(!showPassword())}1010 aria-label="Reveal password"1011 >1012 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1013 <EyeClosedGlyph />1014 </Show>1015 </button>1016 <button1017 class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1018 onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1019 aria-label="Copy password"1020 >1021 <CopyGlyph /><span class="peach-copy-sheen" />1022 </button>1023 </div>1024 </div>1025 }1026 >1027 {}1028 <div1029 class="peach-inline-field-wrap"1030 onFocusOut={(event) => handleEditorFocusOut("password", event)}1031 >1032 <div class="peach-password-edit-row">1033 <input1034 class="peach-inline-input"1035 type={showPassword() ? "text" : "password"}1036 placeholder="paste or type a password"1037 value={draft()}1038 onInput={(e) => setDraft(e.currentTarget.value)}1039 onKeyDown={(e) => {1040 if (e.key === "Enter") { e.preventDefault(); void saveField(); }1041 if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1042 }}1043 ref={autoFocus}1044 disabled={saving()}1045 autocomplete="new-password"1046 />1047 <button1048 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1049 type="button"1050 onClick={() => setShowPassword(!showPassword())}1051 aria-label={showPassword() ? "Hide" : "Show"}1052 >1053 <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1054 <EyeClosedGlyph />1055 </Show>1056 </button>1057 </div>1058 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1059 <button1060 class="peach-generate-toggle-link"1061 type="button"1062 onClick={() => setShowGenerator(!showGenerator())}1063 >1064 <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1065 <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1066 <path d="M9.5 2.5H11V1" />1067 </svg>1068 {showGenerator() ? "hide generator" : "generate"}1069 </button>1070 <Show when={showGenerator()}>1071 <GeneratorDrawer1072 open={true}1073 compact={true}1074 onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1075 />1076 </Show>1077 </div>1078 </Show>1079 </div>10801081 </section>1082 </Show>10831084 {}1085 <Show when={entry().type !== "note" && entry().type !== "card"}>1086 <Show1087 when={entry().notes || editingField() === "notes"}1088 fallback={1089 <button1090 class="peach-add-field-link peach-entry-add-link"1091 onClick={() => startEdit("notes", "")}1092 >1093 + notes1094 </button>1095 }1096 >1097 <div style="position:relative;z-index:1;margin-top:6px;">1098 <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1099 <Show1100 when={editingField() === "notes"}1101 fallback={1102 <div1103 class="peach-note-content peach-inline-clickable"1104 style="cursor:text;white-space:pre-wrap;"1105 onClick={() => startEdit("notes", entry().notes ?? "")}1106 >1107 {entry().notes}1108 </div>1109 }1110 >1111 <div1112 class="peach-inline-field-wrap"1113 onFocusOut={(event) => handleEditorFocusOut("notes", event)}1114 >1115 <textarea1116 class="peach-inline-textarea"1117 value={draft()}1118 onInput={(e) => setDraft(e.currentTarget.value)}1119 onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1120 ref={(el) => requestAnimationFrame(() => el?.focus())}1121 rows={4}1122 disabled={saving()}1123 />1124 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1125 </div>1126 </Show>1127 </div>1128 </Show>1129 </Show>11301131 {}1132 <Show1133 when={editingTags()}1134 fallback={1135 <Show when={!entry().tags || entry().tags!.length === 0}>1136 <button1137 class="peach-add-field-link peach-entry-add-link"1138 onClick={startEditTags}1139 >1140 + tags1141 </button>1142 </Show>1143 }1144 >1145 <div1146 class="peach-inline-tag-editor"1147 onFocusOut={(e) => {1148 const next = e.relatedTarget;1149 if (next instanceof Node && e.currentTarget.contains(next)) return;1150 void saveTags();1151 }}1152 >1153 <span class="peach-label">tags</span>1154 <div class="peach-tag-input-wrap">1155 <For each={tagsDraft()}>1156 {(tag) => (1157 <span class="peach-tag-chip">1158 {tag}1159 <button1160 class="peach-tag-chip-remove"1161 onMouseDown={(e) => e.preventDefault()}1162 onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1163 aria-label={`Remove ${tag}`}1164 >×</button>1165 </span>1166 )}1167 </For>1168 <input1169 class="peach-tag-input"1170 placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1171 value={tagInput()}1172 onInput={(e) => setTagInput(e.currentTarget.value)}1173 onKeyDown={(e) => {1174 if (e.key === "Enter" || e.key === ",") {1175 e.preventDefault();1176 addTag(tagInput());1177 } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1178 setTagsDraft(tagsDraft().slice(0, -1));1179 } else if (e.key === "Escape") {1180 e.preventDefault();1181 setEditingTags(false);1182 setTagInput("");1183 }1184 }}1185 ref={(el) => requestAnimationFrame(() => el?.focus())}1186 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1187 />1188 </div>1189 </div>1190 </Show>11911192 {}1193 <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1194 <Show1195 when={totpState() || editingField() === "totp"}1196 fallback={1197 <button1198 class="peach-add-field-link peach-entry-add-link"1199 onClick={() => startEdit("totp", "")}1200 >1201 + 2FA1202 </button>1203 }1204 >1205 <section class="peach-totp-band">1206 <Show1207 when={editingField() === "totp"}1208 fallback={1209 <Show when={totpState()}>1210 {(state) => (1211 <>1212 <div1213 class="peach-totp-main"1214 onClick={() => {1215 1216 1217 if (totpClickTimer) return;1218 totpClickTimer = setTimeout(() => {1219 totpClickTimer = null;1220 void copyField(state().code.replace(" ", ""), "totp");1221 }, 220);1222 }}1223 onDblClick={() => {1224 if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1225 startEdit("totp", entry().totp ?? "");1226 }}1227 title="Click to copy · double-click to edit"1228 >1229 <span class="peach-label">2-factor</span>1230 <div class="peach-totp-code-row">1231 <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1232 <span class="peach-totp-code-text">1233 {state().code.slice(0, 3)} {state().code.slice(3)}1234 </span>1235 </p>1236 </div>1237 </div>1238 <div class="peach-totp-meta">1239 <span class="peach-label">expires</span>1240 <div class="peach-totp-countdown">{state().remaining}s</div>1241 <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1242 <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1243 <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1244 stroke-dasharray="106.8"1245 stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1246 />1247 </svg>1248 </div>1249 </>1250 )}1251 </Show>1252 }1253 >1254 <div1255 class="peach-inline-field-wrap peach-inline-field-wrap--totp"1256 onFocusOut={(event) => handleEditorFocusOut("totp", event)}1257 >1258 <span class="peach-label">2-factor</span>1259 <input1260 class="peach-inline-input"1261 value={draft()}1262 onInput={(e) => setDraft(e.currentTarget.value)}1263 onKeyDown={onEditKeyDown}1264 ref={autoFocus}1265 disabled={saving()}1266 placeholder="otpauth://... or base32 secret"1267 autocomplete="off"1268 />1269 <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1270 </div>1271 </Show>1272 </section>1273 </Show>1274 </Show>12751276 {}1277 <Show when={entry().type === "login"}>1278 <button1279 class="peach-add-field-link peach-entry-add-link"1280 onClick={() => setShowAdvanced((v) => !v)}1281 aria-expanded={showAdvanced()}1282 >1283 {showAdvanced() ? "− Advanced" : "+ Advanced"}1284 </button>1285 <Show when={showAdvanced()}>1286 <Show when={!isPasskeyOnly(entry())}>1287 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1288 </Show>12891290 <Show when={entry().passkey}>1291 {(passkey) => {1292 const currentBrowser = detectBrowser();1293 const crossBrowser =1294 passkey().browser &&1295 currentBrowser !== "unknown" &&1296 passkey().browser !== currentBrowser;1297 const algorithmLabel =1298 passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1299 const formatDate = (ts?: number) =>1300 ts ? new Date(ts).toLocaleDateString() : "Never";1301 return (1302 <section1303 class="peach-fields peach-passkey-section"1304 style="position:relative;z-index:1;margin-top:6px;"1305 >1306 <div class="peach-field-card">1307 <span class="peach-label">passkey · {passkey().rpId}</span>1308 <div class="peach-field-value-row">1309 <span class="peach-field-value">{passkey().userName}</span>1310 <span class="peach-passkey-badge">passkey</span>1311 </div>1312 </div>13131314 <div class="peach-field-card">1315 <span class="peach-label">algorithm</span>1316 <div class="peach-field-value-row">1317 <span class="peach-field-value">{algorithmLabel}</span>1318 </div>1319 </div>13201321 <div class="peach-field-card">1322 <span class="peach-label">activity</span>1323 <div class="peach-field-value-row peach-passkey-activity">1324 <span class="peach-field-value">1325 used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1326 </span>1327 <span class="peach-passkey-meta">1328 created {formatDate(passkey().createdAt)}1329 </span>1330 <span class="peach-passkey-meta">1331 last used {formatDate(passkey().lastUsedAt)}1332 </span>1333 </div>1334 </div>13351336 <Show when={crossBrowser}>1337 <div class="peach-info-notice peach-info-notice--warn">1338 {`This passkey was created on ${1339 passkey().browser === "chrome" ? "Chrome" : "Firefox"1340 }. To use it on ${1341 currentBrowser === "chrome" ? "Chrome" : "Firefox"1342 }, re-register it on this device.`}1343 </div>1344 </Show>13451346 <div class="peach-info-notice">1347 {passkey().browser === "firefox"1348 ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1349 : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1350 </div>13511352 <button1353 class="peach-add-field-link peach-entry-add-link"1354 style="color:rgba(220,38,38,0.7);"1355 onClick={async () => {1356 const id = entry().id;1357 await sendMessage({1358 type: "UPDATE_ENTRY",1359 payload: { id, patch: { passkey: undefined } },1360 });1361 await props.onFieldSaved();1362 }}1363 >1364 − remove passkey1365 </button>1366 </section>1367 );1368 }}1369 </Show>1370 </Show>1371 </Show>13721373 {}1374 <div style="height:4px;" />1375 </>1376 )}1377 </Show>1378 </div>13791380 </>1381 );1382}1383138413851386function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1387 return (1388 <div class="peach-inline-actions">1389 <button1390 class="peach-inline-save-btn"1391 onClick={props.onSave}1392 disabled={props.saving}1393 aria-label="Save"1394 title="Save (Enter)"1395 >1396 <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1397 <path d="M1 4.5L4 7.5L10 1.5" />1398 </svg>1399 </button>1400 <button1401 class="peach-inline-cancel-btn"1402 onClick={props.onCancel}1403 disabled={props.saving}1404 aria-label="Cancel"1405 title="Cancel (Esc)"1406 >1407 <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1408 <path d="M1 1L8 8M8 1L1 8" />1409 </svg>1410 </button>1411 </div>1412 );1413}14141415function EmptyVaultState() {1416 return (1417 <>1418 <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1419 <div class="peach-entry-name">1420 <span class="peach-entry-name-text">1421 <span class="peach-entry-name-char" style="--char-index:0">e</span>1422 <span class="peach-entry-name-char" style="--char-index:1">m</span>1423 <span class="peach-entry-name-char" style="--char-index:2">p</span>1424 <span class="peach-entry-name-char" style="--char-index:3">t</span>1425 <span class="peach-entry-name-char" style="--char-index:4">y</span>1426 </span>1427 </div>1428 </div>1429 <div class="peach-empty-copy">1430 <span class="peach-label">vault</span>1431 <p>Your vault is ready for its first entry.</p>1432 </div>1433 </>1434 );1435}14361437function CopyGlyph() {1438 return (1439 <span class="peach-copy-icon">1440 <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">1441 <rect x="4" y="4" width="8" height="9" rx="1.5" />1442 <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" />1443 </svg>1444 </span>1445 );1446}14471448function EyeClosedGlyph() {1449 return (1450 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1451 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1452 <path d="M3.5 5.5L2.5 7.5" />1453 <path d="M7 6.5V8.5" />1454 <path d="M10.5 5.5L11.5 7.5" />1455 </svg>1456 );1457}14581459function EyeOpenGlyph() {1460 return (1461 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1462 <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" />1463 <circle cx="7" cy="5" r="1.75" />1464 </svg>1465 );1466}14671468function ChevronGlyph(props: { open: boolean }) {1469 return (1470 <svg1471 width="8"1472 height="8"1473 viewBox="0 0 8 8"1474 fill="none"1475 stroke="currentColor"1476 stroke-width="1.3"1477 stroke-linecap="round"1478 stroke-linejoin="round"1479 style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1480 >1481 <path d="M2 1L6 4L2 7" />1482 </svg>1483 );1484}14851486function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1487 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1488 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1489 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1490 const [testResult, setTestResult] = createSignal<string | null>(null);1491 const [testing, setTesting] = createSignal(false);14921493 async function save() {1494 const url = webhookUrl().trim();1495 const nextLabel = label().trim() || undefined;1496 const canary = enabled() && url1497 ? {1498 webhookUrl: url,1499 label: nextLabel,1500 lastPinged: props.entry.canary?.lastPinged,1501 pingCount: props.entry.canary?.pingCount,1502 }1503 : undefined;1504 await sendMessage({1505 type: "UPDATE_ENTRY",1506 payload: { id: props.entry.id, patch: { canary } },1507 });1508 await props.onSaved();1509 }15101511 async function testPing() {1512 const url = webhookUrl().trim();1513 if (!url) return;1514 setTesting(true);1515 setTestResult(null);1516 const res = await sendMessage({1517 type: "FIRE_CANARY_PING",1518 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1519 }) as { ok: boolean; error?: string };1520 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1521 setTesting(false);1522 }15231524 return (1525 <div class="peach-canary-section">1526 <div class="peach-field-value-row">1527 <span class="peach-field-value" style="font-size:12px;">canary alert</span>1528 <button1529 class={`peach-toggle ${enabled() ? "is-on" : ""}`}1530 onClick={() => { setEnabled(!enabled()); void save(); }}1531 aria-label="Toggle canary"1532 >1533 <span class="peach-toggle-knob" />1534 </button>1535 </div>1536 <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1537 Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1538 </p>1539 <Show when={enabled()}>1540 <div class="peach-inline-field-wrap" style="margin-top:8px;">1541 <input1542 class="peach-inline-input"1543 placeholder="https://hooks.zapier.com/..."1544 value={webhookUrl()}1545 onInput={(e) => setWebhookUrl(e.currentTarget.value)}1546 onBlur={() => void save()}1547 />1548 <input1549 class="peach-inline-input"1550 placeholder="label (optional, e.g. AWS decoy)"1551 value={label()}1552 onInput={(e) => setLabel(e.currentTarget.value)}1553 onBlur={() => void save()}1554 />1555 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1556 <button1557 class="peach-btn peach-btn--ghost peach-btn--sm"1558 onClick={() => void testPing()}1559 disabled={testing() || !webhookUrl().trim()}1560 >1561 {testing() ? "sending..." : "test ping"}1562 </button>1563 <Show when={testResult()}>1564 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1565 </Show>1566 </div>1567 <Show when={props.entry.canary?.lastPinged}>1568 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1569 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1570 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1571 </p>1572 </Show>1573 </div>1574 </Show>1575 </div>1576 );1577}