Entry.tsx

64.4 KB · raw

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  /**18   * Current active tab's full URL (https only). Empty string when unavailable19   * — Capacitor, an extension page, or a chrome:///about: URL. The URL20   * editor uses this to render an "add current site" shortcut.21   */22  currentTabUrl?: string;23  onDelete: () => void;24  /** Called after any inline save so App can reload entries */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  // Local draft for the additional URLs list, edited only while the URL field37  // is in inline-edit mode. Saved alongside `url` when the user confirms.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  // Auto-open advanced when entry already has passkey or canary configured50  const [showAdvanced, setShowAdvanced] = createSignal(false);51  let lastSavedNote: string | null = null;5253  function noteExec(cmd: string, val?: string) {54    document.execCommand(cmd, false, val);55    noteEditorEl()?.focus();56  }5758  async function saveNoteInline(html: string) {59    const entry = props.entry;60    if (!entry) return;61    const currentStored = entry.notes ?? "";62    if (html === currentStored) return;63    if (lastSavedNote !== null && html === lastSavedNote) return;64    lastSavedNote = html;65    setSaving(true);66    try {67      await sendMessage({68        type: "UPDATE_ENTRY",69        payload: { id: entry.id, patch: { notes: html } },70      });71      triggerRing();72      await props.onFieldSaved();73    } catch {74      lastSavedNote = null;75    } finally {76      setSaving(false);77    }78  }7980  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81  onCleanup(() => window.clearInterval(interval));8283  const displayDomain = createMemo(() =>84    props.entry?.url ? getDisplayDomain(props.entry.url) : ""85  );8687  createEffect(() => {88    const domain = displayDomain();89    setFaviconSrc(domain ? faviconUrl(domain) : null);90  });9192  // Reset transient prompts (trash confirmation, inline edits, advanced panel)93  // when the selected entry changes so they don't carry over between entries.94  createEffect(() => {95    void props.entry?.id;96    setConfirmDelete(false);97    setEditingField(null);98    setEditingTags(false);99    setShowAdvanced(false);100  });101102  const totpState = createMemo(() => {103    void totpTick();104    if (!props.entry?.totp) return null;105    return generateTOTP(props.entry.totp);106  });107108  let prevTotpCode: string | undefined;109  createEffect(() => {110    const state = totpState();111    if (!state) { prevTotpCode = undefined; return; }112    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {113      setTotpHot(true);114      window.setTimeout(() => setTotpHot(false), 3000);115    }116    prevTotpCode = state.code;117  });118119  const totpProgress = createMemo(() => {120    const state = totpState();121    if (!state) return 0;122    return ((state.remaining - 1) / 29) * 100;123  });124125  createEffect(() => {126    const shell = document.getElementById("hd-shell");127    if (!shell) return;128129    const state = totpState();130    if (!state) {131      delete shell.dataset.totpActive;132      shell.style.removeProperty("--peach-totp-progress");133      return;134    }135136    shell.dataset.totpActive = "true";137    shell.style.setProperty(138      "--peach-totp-progress",139      `${Math.max(0, totpProgress())}%`140    );141142    onCleanup(() => {143      delete shell.dataset.totpActive;144      shell.style.removeProperty("--peach-totp-progress");145    });146  });147148  const titleSizeClass = createMemo(() => {149    const length = props.entry?.title.length ?? 0;150    if (length >= 28) return "peach-entry-name--xxl";151    if (length >= 20) return "peach-entry-name--xl";152    if (length >= 14) return "peach-entry-name--lg";153    return "";154  });155  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);156157  // ── Inline editing ────────────────────────────────────────────────158159  function startEdit(field: EditableField, value: string) {160    setEditingField(field);161    setDraft(value);162    setShowGenerator(false);163    setConfirmDelete(false);164    if (field === "url") {165      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);166    }167  }168169  // Compare URLs by string after trim — vault.ts has its own canonicalisation170  // when saving, so the dedupe here is a UX-level "don't show the button if171  // we'd just add a duplicate" rather than a security check.172  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {173    const c = candidate.trim();174    if (!c) return true;175    if (primary.trim() === c) return true;176    return others.some((u) => u.trim() === c);177  }178179  function addCurrentSite(url: string) {180    if (!url) return;181    if (!draft().trim()) {182      setDraft(url);183      return;184    }185    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;186    setAdditionalUrlsDraft((arr) => [...arr, url]);187  }188189  async function saveField() {190    const field = editingField();191    const entry = props.entry;192    if (!field || !entry) return;193    // Don't trim passwords — spaces may be intentional194    let value = field === "password" ? draft() : draft().trim();195    if (field === "totp" && value.startsWith("otpauth://")) {196      const parsed = parseOtpauthURI(value);197      value = parsed?.secret ?? value;198    }199    if (field === "password" && entry.type !== "note" && !value) {200      return;201    }202    setSaving(true);203    try {204      const patch: Partial<VaultEntry> = { [field]: value };205      if (field === "url") {206        // Sanitization layer in vault.ts trims, dedupes, and drops empties.207        patch.additionalUrls = additionalUrlsDraft();208      }209      await sendMessage({210        type: "UPDATE_ENTRY",211        payload: { id: entry.id, patch },212      });213      triggerRing();214      setEditingField(null);215      setShowGenerator(false);216      await props.onFieldSaved();217    } catch {218      // silent — leave editing open219    } finally {220      setSaving(false);221    }222  }223224  function startEditTags() {225    setTagsDraft([...(props.entry?.tags ?? [])]);226    setTagInput("");227    setEditingTags(true);228  }229230  function addTag(value: string) {231    const n = value.toLowerCase().trim();232    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;233    setTagsDraft([...tagsDraft(), n]);234    setTagInput("");235  }236237  async function saveTags() {238    if (tagInput()) addTag(tagInput());239    const entry = props.entry;240    if (!entry) return;241    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;242    setEditingTags(false);243    setSaving(true);244    try {245      await sendMessage({246        type: "UPDATE_ENTRY",247        payload: { id: entry.id, patch: { tags: next } },248      });249      triggerRing();250      await props.onFieldSaved();251    } catch {252      // silent253    } finally {254      setSaving(false);255    }256  }257258  function cancelEdit() {259    setEditingField(null);260    setDraft("");261    setShowGenerator(false);262  }263264  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {265    switch (field) {266      case "title":267        return entry.title;268      case "url":269        return entry.url ?? "";270      case "username":271        return entry.username ?? "";272      case "email":273        return entry.email ?? "";274      case "password":275        return entry.password ?? "";276      case "notes":277        return entry.notes ?? "";278      case "totp":279        return entry.totp ?? "";280    }281  }282283  function handleEditorFocusOut(284    field: Exclude<EditableField, null>,285    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }286  ) {287    const nextTarget = event.relatedTarget;288    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;289290    const entry = props.entry;291    if (!entry) return;292293    const currentValue = fieldValue(entry, field);294    const nextValue = field === "password" ? draft() : draft().trim();295    if (nextValue === currentValue) {296      cancelEdit();297    }298  }299300  async function savePassword(password: string) {301    const entry = props.entry;302    if (!entry) return;303    setSaving(true);304    try {305      await sendMessage({306        type: "UPDATE_ENTRY",307        payload: { id: entry.id, patch: { password } },308      });309      triggerRing();310      setShowGenerator(false);311      await props.onFieldSaved();312    } catch {313      // silent314    } finally {315      setSaving(false);316    }317  }318319  // ── Copy ─────────────────────────────────────────────────────────320321  async function copyField(text: string, label: string, button?: HTMLButtonElement) {322    const ok = await popupCopy(text, label);323    if (ok) {324      const entry = props.entry;325      if (entry?.canary?.webhookUrl) {326        const trigger =327          label === "password"328            ? "password_copied"329            : label === "username"330              ? "username_copied"331              : undefined;332        if (trigger) {333          void sendMessage({334            type: "FIRE_CANARY_PING",335            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },336          });337        }338      }339      triggerRing();340      triggerPipPulse();341      setCopied(null);342      requestAnimationFrame(() => setCopied(label));343      if (button) {344        button.classList.remove("is-copied", "is-retreat");345        void button.offsetWidth;346        const drain = document.createElement("span");347        drain.className = "peach-copy-drain";348        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";349        drain.appendChild(bar);350        button.appendChild(drain);351        window.setTimeout(() => {352          button.classList.remove("is-copied");353          button.classList.add("is-retreat");354          drain.remove();355          window.setTimeout(() => button.classList.remove("is-retreat"), 160);356        }, 1800);357      }358      window.setTimeout(() => setCopied(null), 1800);359    }360  }361362  // ── Keyboard shortcuts ────────────────────────────────────────────363364  createEffect(() => {365    if (!props.entry) return;366    const handler = (e: KeyboardEvent) => {367      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;368      if (inInput) return;369      switch (e.key) {370        case "c": e.preventDefault();371          if (props.entry?.password) void copyField(props.entry.password, "password"); break;372        case "u": e.preventDefault();373          if (props.entry?.username) void copyField(props.entry.username, "username"); break;374        case "Escape": e.preventDefault(); props.onEscape?.(); break;375      }376    };377    window.addEventListener("keydown", handler);378    onCleanup(() => window.removeEventListener("keydown", handler));379  });380381  // ── Input ref focus helper ────────────────────────────────────────382383  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {384    requestAnimationFrame(() => { el.focus(); el.select?.(); });385  }386387  function onEditKeyDown(e: KeyboardEvent) {388    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {389      e.preventDefault();390      void saveField();391    }392    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }393  }394395  return (396    <>397      <div398        class="peach-ghost"399        aria-hidden="true"400        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;"401      >402        {props.entry?.title || "empty"}403      </div>404405      <div class="peach-stage" style="position:relative;">406        <Show when={props.entry} fallback={<EmptyVaultState />}>407          {(entry) => (408            <>409              {/* ── Breach strip ─────────────────────────────────── */}410              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>411                <div class="peach-breach-badge">412                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">413                    <path d="M6 1L11 10H1L6 1z" />414                    <line x1="6" y1="5" x2="6" y2="7.5" />415                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />416                  </svg>417                  <span style="flex:1">found in a known data breach</span>418                  <button419                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"420                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}421                  >422                    Change password423                  </button>424                </div>425              </Show>426427              {/* ── Title + trash ─────────────────────────────────── */}428              <div class="peach-entry-header">429                <div class="peach-entry-header-name">430                  <Show431                    when={editingField() === "title"}432                    fallback={433                      <button434                        class="peach-entry-name-button"435                        title="Click to edit title"436                        onClick={() => startEdit("title", entry().title)}437                      >438                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>439                          <span class="peach-entry-name-text">440                            <Show441                              when={useAnimatedTitle()}442                              fallback={entry().title}443                            >444                              <For each={entry().title.split("")}>445                                {(ch, i) => (446                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>447                                    {ch === " " ? "\u00A0" : ch}448                                  </span>449                                )}450                              </For>451                            </Show>452                          </span>453                        </h1>454                      </button>455                    }456                  >457                    <div458                      class="peach-inline-title-wrap"459                      onFocusOut={(event) => handleEditorFocusOut("title", event)}460                    >461                      <input462                        class="peach-inline-title-input"463                        value={draft()}464                        onInput={(e) => setDraft(e.currentTarget.value)}465                        onKeyDown={onEditKeyDown}466                        ref={autoFocus}467                        disabled={saving()}468                      />469                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />470                    </div>471                  </Show>472                </div>473474                {/* Subtle trash icon — hidden while editing title */}475                <Show when={editingField() !== "title"}>476                  <Show477                    when={confirmDelete()}478                    fallback={479                      <button480                        class="peach-entry-trash-btn"481                        onClick={() => setConfirmDelete(true)}482                        aria-label="Move to trash"483                        title="Delete entry"484                      >485                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">486                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />487                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />488                        </svg>489                      </button>490                    }491                  >492                    <div class="peach-entry-trash-confirm">493                      <span class="peach-entry-trash-confirm-label">trash?</span>494                      <button495                        class="peach-btn peach-btn--sm peach-btn--danger"496                        style="font-size:10px;padding:2px 8px;min-height:22px;"497                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}498                      >499                        yes500                      </button>501                      <button502                        class="peach-btn peach-btn--ghost peach-btn--sm"503                        style="font-size:10px;padding:2px 8px;min-height:22px;"504                        onClick={() => setConfirmDelete(false)}505                      >506                        no507                      </button>508                    </div>509                  </Show>510                </Show>511              </div>512513              {/* ── Domain / URL ─────────────────────────────────── */}514              <Show when={entry().type !== "note" && entry().type !== "card"}>515                <div class="peach-domain" style="position:relative;z-index:1;">516                  <Show517                    when={editingField() === "url"}518                    fallback={519                      <div520                        class="peach-domain-row peach-inline-clickable"521                        title="Click to edit URL"522                        onClick={() => startEdit("url", entry().url ?? "")}523                      >524                        <Show when={faviconSrc()}>525                          <img526                            src={faviconSrc()!}527                            width="16"528                            height="16"529                            alt=""530                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"531                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}532                          />533                        </Show>534                        <span class="peach-domain-value">535                          {displayDomain() || (entry().url ? entry().url : "add a website url")}536                        </span>537                        <span class="peach-inline-edit-hint">edit</span>538                      </div>539                    }540                  >541                    <div542                      class="peach-inline-field-wrap peach-inline-field-wrap--url"543                      onFocusOut={(event) => handleEditorFocusOut("url", event)}544                    >545                      <div class="peach-inline-field-row">546                        <input547                          class="peach-inline-input"548                          type="url"549                          placeholder="https://"550                          value={draft()}551                          onInput={(e) => setDraft(e.currentTarget.value)}552                          onKeyDown={onEditKeyDown}553                          ref={autoFocus}554                          disabled={saving()}555                        />556                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />557                      </div>558                      <div class="peach-additional-urls">559                        <Show when={additionalUrlsDraft().length > 0}>560                          <span class="peach-label peach-additional-urls-label">also matches</span>561                        </Show>562                        <For each={additionalUrlsDraft()}>563                          {(value, i) => (564                            <div class="peach-additional-url-row">565                              <input566                                class="peach-inline-input"567                                type="url"568                                placeholder="https://"569                                value={value}570                                onInput={(e) => setAdditionalUrlsDraft((arr) => {571                                  const next = [...arr];572                                  next[i()] = e.currentTarget.value;573                                  return next;574                                })}575                                disabled={saving()}576                              />577                              <button578                                class="peach-additional-url-remove"579                                type="button"580                                aria-label="Remove URL"581                                onMouseDown={(e) => e.preventDefault()}582                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}583                                disabled={saving()}584                              >×</button>585                            </div>586                          )}587                        </For>588                        <button589                          class="peach-add-field-link peach-additional-urls-add"590                          type="button"591                          onMouseDown={(e) => e.preventDefault()}592                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}593                          disabled={saving()}594                        >595                          + add another url596                        </button>597                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>598                          <button599                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"600                            type="button"601                            title={props.currentTabUrl}602                            onMouseDown={(e) => e.preventDefault()}603                            onClick={() => addCurrentSite(props.currentTabUrl!)}604                            disabled={saving()}605                          >606                            + add current site607                          </button>608                        </Show>609                      </div>610                    </div>611                  </Show>612                </div>613              </Show>614615              {/* ── Tags — read-only chips (editing state lives at bottom) */}616              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>617                <div618                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"619                  onClick={startEditTags}620                  title="Click to edit tags"621                >622                  <For each={entry().tags!}>623                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}624                  </For>625                </div>626              </Show>627628              {/* ── Note toolbar + inline editor ─────────────────── */}629              <Show when={entry().type === "note"}>630                <div class="peach-entry-note-toolbar-row">631                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />632                </div>633                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">634                  <NoteEditorInline635                    entryKey={entry().id}636                    initialContent={entry().notes ?? ""}637                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}638                    onBlur={saveNoteInline}639                    autoFocus={false}640                  />641                </section>642              </Show>643644              {/* ── Card detail view ─────────────────────────────── */}645              <Show when={entry().type === "card"}>646                <section class="peach-fields" style="position:relative;z-index:1;">647                  {/* Card subtitle: •••• last4 */}648                  <Show when={entry().last4}>649                    <div class="peach-field-card" style="padding-bottom:4px;">650                      <span class="peach-label">651                        <Show when={entry().network && entry().network !== "unknown"}>652                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>653                        </Show>654                        {entry().cardNickname || `•••• ${entry().last4}`}655                      </span>656                    </div>657                  </Show>658659                  {/* Cardholder name */}660                  <div class="peach-field-card">661                    <span class="peach-label">cardholder name</span>662                    <div class="peach-field-value-row">663                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>664                      <Show when={entry().cardholderName}>665                        <button666                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}667                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}668                          aria-label="Copy cardholder name"669                        >670                          <CopyGlyph /><span class="peach-copy-sheen" />671                        </button>672                      </Show>673                    </div>674                  </div>675676                  {/* Card number */}677                  <div class="peach-field-card">678                    <span class="peach-label">card number</span>679                    <div class="peach-field-value-row">680                      <span class={`peach-field-value peach-card-number`}>681                        <Show when={showCardNumber()} fallback={682                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>683                        }>684                          <span>{entry().cardNumber ?? ""}</span>685                        </Show>686                      </span>687                      <div class="peach-field-actions">688                        <button689                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"690                          onClick={() => setShowCardNumber(!showCardNumber())}691                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}692                        >693                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>694                            <EyeClosedGlyph />695                          </Show>696                        </button>697                        <Show when={entry().cardNumber}>698                          <button699                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}700                            onClick={(e) => {701                              const cardNum = entry().cardNumber!;702                              if (entry().canary?.webhookUrl) {703                                void sendMessage({704                                  type: "FIRE_CANARY_PING",705                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },706                                });707                              }708                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);709                            }}710                            aria-label="Copy card number"711                          >712                            <CopyGlyph /><span class="peach-copy-sheen" />713                          </button>714                        </Show>715                      </div>716                    </div>717                  </div>718719                  {/* Expiry */}720                  <Show when={entry().expMonth || entry().expYear}>721                    <div class="peach-field-card">722                      <span class="peach-label">expiry</span>723                      <div class="peach-field-value-row">724                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>725                        <button726                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}727                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}728                          aria-label="Copy expiry"729                        >730                          <CopyGlyph /><span class="peach-copy-sheen" />731                        </button>732                      </div>733                    </div>734                  </Show>735736                  {/* CVC */}737                  <Show when={entry().cvc}>738                    <div class="peach-field-card">739                      <span class="peach-label">CVC</span>740                      <div class="peach-field-value-row">741                        <span class="peach-field-value">742                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>743                            <span>{entry().cvc}</span>744                          </Show>745                        </span>746                        <div class="peach-field-actions">747                          <button748                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"749                            onClick={() => setShowCardCvc(!showCardCvc())}750                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}751                          >752                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>753                              <EyeClosedGlyph />754                            </Show>755                          </button>756                          <button757                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}758                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}759                            aria-label="Copy CVC"760                          >761                            <CopyGlyph /><span class="peach-copy-sheen" />762                          </button>763                        </div>764                      </div>765                    </div>766                  </Show>767                </section>768              </Show>769770              {/* ── Login fields ─────────────────────────────────── */}771              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>772                <section class="peach-fields" style="position:relative;z-index:1;">773774                  {/* Username — shown when set, or as the only identity field when neither is set */}775                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>776                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>777                      <span class="peach-label">{entry().username?.includes("@") ? "email" : "username"}</span>778                      <Show779                        when={editingField() === "username"}780                        fallback={781                          <div class="peach-field-value-row">782                            <span783                              class="peach-field-value peach-inline-clickable"784                              title="Click to edit"785                              onClick={() => startEdit("username", entry().username ?? "")}786                            >787                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}788                            </span>789                            <Show when={entry().username}>790                              <button791                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}792                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}793                                aria-label="Copy username"794                              >795                                <CopyGlyph /><span class="peach-copy-sheen" />796                              </button>797                            </Show>798                          </div>799                        }800                      >801                        <div802                          class="peach-inline-field-wrap"803                          onFocusOut={(event) => handleEditorFocusOut("username", event)}804                        >805                          <input806                            class="peach-inline-input"807                            value={draft()}808                            onInput={(e) => setDraft(e.currentTarget.value)}809                            onKeyDown={onEditKeyDown}810                            ref={autoFocus}811                            disabled={saving()}812                            autocomplete="off"813                          />814                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />815                          <Show when={!entry().email}>816                            <button817                              class="peach-add-field-btn"818                              type="button"819                              onMouseDown={(e) => e.preventDefault()}820                              onClick={() => startEdit("email", "")}821                            >822                              + add email823                            </button>824                          </Show>825                        </div>826                      </Show>827                    </div>828                  </Show>829830                  {/* Email — shown when set, or when actively being added */}831                  <Show when={entry().email || editingField() === "email"}>832                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>833                      <span class="peach-label">email</span>834                      <Show835                        when={editingField() === "email"}836                        fallback={837                          <div class="peach-field-value-row">838                            <span839                              class="peach-field-value peach-inline-clickable"840                              title="Click to edit"841                              onClick={() => startEdit("email", entry().email ?? "")}842                            >843                              {entry().email}844                            </span>845                            <button846                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}847                              onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}848                              aria-label="Copy email"849                            >850                              <CopyGlyph /><span class="peach-copy-sheen" />851                            </button>852                          </div>853                        }854                      >855                        <div856                          class="peach-inline-field-wrap"857                          onFocusOut={(event) => handleEditorFocusOut("email", event)}858                        >859                          <input860                            class="peach-inline-input"861                            value={draft()}862                            onInput={(e) => setDraft(e.currentTarget.value)}863                            onKeyDown={onEditKeyDown}864                            ref={autoFocus}865                            disabled={saving()}866                            autocomplete="email"867                          />868                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />869                          <Show when={!entry().username}>870                            <button871                              class="peach-add-field-btn"872                              type="button"873                              onMouseDown={(e) => e.preventDefault()}874                              onClick={() => startEdit("username", "")}875                            >876                              + add username877                            </button>878                          </Show>879                        </div>880                      </Show>881                    </div>882                  </Show>883884                  {/* Password */}885                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>886                    <span class="peach-label">password</span>887                    <Show888                      when={editingField() === "password"}889                      fallback={890                        <div class="peach-field-value-row">891                          <span892                            class="peach-password-value"893                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}894                            style="cursor:pointer;"895                            title="Click to edit password"896                            onClick={() => startEdit("password", entry().password ?? "")}897                          >898                            <Show899                              when={showPassword()}900                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}901                            >902                              <span class="peach-password-text">{entry().password ?? ""}</span>903                            </Show>904                          </span>905                          <div class="peach-field-actions">906                            <button907                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"908                              onClick={() => setShowPassword(!showPassword())}909                              aria-label="Reveal password"910                            >911                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>912                                <EyeClosedGlyph />913                              </Show>914                            </button>915                            <button916                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}917                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}918                              aria-label="Copy password"919                            >920                              <CopyGlyph /><span class="peach-copy-sheen" />921                            </button>922                          </div>923                        </div>924                      }925                    >926                      {/* ── Edit mode: type / paste / generate ── */}927                      <div928                        class="peach-inline-field-wrap"929                        onFocusOut={(event) => handleEditorFocusOut("password", event)}930                      >931                        <div class="peach-password-edit-row">932                          <input933                            class="peach-inline-input"934                            type={showPassword() ? "text" : "password"}935                            placeholder="paste or type a password"936                            value={draft()}937                            onInput={(e) => setDraft(e.currentTarget.value)}938                            onKeyDown={(e) => {939                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }940                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }941                            }}942                            ref={autoFocus}943                            disabled={saving()}944                            autocomplete="new-password"945                          />946                          <button947                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"948                            type="button"949                            onClick={() => setShowPassword(!showPassword())}950                            aria-label={showPassword() ? "Hide" : "Show"}951                          >952                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>953                              <EyeClosedGlyph />954                            </Show>955                          </button>956                        </div>957                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />958                        <button959                          class="peach-generate-toggle-link"960                          type="button"961                          onClick={() => setShowGenerator(!showGenerator())}962                        >963                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">964                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />965                            <path d="M9.5 2.5H11V1" />966                          </svg>967                          {showGenerator() ? "hide generator" : "generate"}968                        </button>969                        <Show when={showGenerator()}>970                          <GeneratorDrawer971                            open={true}972                            compact={true}973                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}974                          />975                        </Show>976                      </div>977                    </Show>978                  </div>979980                </section>981              </Show>982983              {/* ── Notes (on login entries) ──────────────────────── */}984              <Show when={entry().type !== "note" && entry().type !== "card"}>985                <Show986                  when={entry().notes || editingField() === "notes"}987                  fallback={988                    <button989                      class="peach-add-field-link peach-entry-add-link"990                      onClick={() => startEdit("notes", "")}991                    >992                      + notes993                    </button>994                  }995                >996                  <div style="position:relative;z-index:1;margin-top:6px;">997                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>998                    <Show999                      when={editingField() === "notes"}1000                      fallback={1001                        <div1002                          class="peach-note-content peach-inline-clickable"1003                          style="cursor:text;white-space:pre-wrap;"1004                          onClick={() => startEdit("notes", entry().notes ?? "")}1005                        >1006                          {entry().notes}1007                        </div>1008                      }1009                    >1010                      <div1011                        class="peach-inline-field-wrap"1012                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1013                      >1014                        <textarea1015                          class="peach-inline-textarea"1016                          value={draft()}1017                          onInput={(e) => setDraft(e.currentTarget.value)}1018                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1019                          ref={(el) => requestAnimationFrame(() => el?.focus())}1020                          rows={4}1021                          disabled={saving()}1022                        />1023                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1024                      </div>1025                    </Show>1026                  </div>1027                </Show>1028              </Show>10291030              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1031              <Show1032                when={editingTags()}1033                fallback={1034                  <Show when={!entry().tags || entry().tags!.length === 0}>1035                    <button1036                      class="peach-add-field-link peach-entry-add-link"1037                      onClick={startEditTags}1038                    >1039                      + tags1040                    </button>1041                  </Show>1042                }1043              >1044                <div1045                  class="peach-inline-tag-editor"1046                  onFocusOut={(e) => {1047                    const next = e.relatedTarget;1048                    if (next instanceof Node && e.currentTarget.contains(next)) return;1049                    void saveTags();1050                  }}1051                >1052                  <span class="peach-label">tags</span>1053                  <div class="peach-tag-input-wrap">1054                    <For each={tagsDraft()}>1055                      {(tag) => (1056                        <span class="peach-tag-chip">1057                          {tag}1058                          <button1059                            class="peach-tag-chip-remove"1060                            onMouseDown={(e) => e.preventDefault()}1061                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1062                            aria-label={`Remove ${tag}`}1063                          >×</button>1064                        </span>1065                      )}1066                    </For>1067                    <input1068                      class="peach-tag-input"1069                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1070                      value={tagInput()}1071                      onInput={(e) => setTagInput(e.currentTarget.value)}1072                      onKeyDown={(e) => {1073                        if (e.key === "Enter" || e.key === ",") {1074                          e.preventDefault();1075                          addTag(tagInput());1076                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1077                          setTagsDraft(tagsDraft().slice(0, -1));1078                        } else if (e.key === "Escape") {1079                          e.preventDefault();1080                          setEditingTags(false);1081                          setTagInput("");1082                        }1083                      }}1084                      ref={(el) => requestAnimationFrame(() => el?.focus())}1085                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1086                    />1087                  </div>1088                </div>1089              </Show>10901091              {/* ── TOTP ─────────────────────────────────────────── */}1092              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1093                <Show1094                  when={totpState() || editingField() === "totp"}1095                  fallback={1096                    <button1097                      class="peach-add-field-link peach-entry-add-link"1098                      onClick={() => startEdit("totp", "")}1099                    >1100                      + 2FA1101                    </button>1102                  }1103                >1104                  <section class="peach-totp-band">1105                    <Show1106                      when={editingField() === "totp"}1107                      fallback={1108                        <Show when={totpState()}>1109                          {(state) => (1110                            <>1111                              <div1112                                class="peach-totp-main"1113                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1114                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1115                                title="Click to copy · double-click to edit"1116                              >1117                                <span class="peach-label">2-factor</span>1118                                <div class="peach-totp-code-row">1119                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1120                                    <span class="peach-totp-code-text">1121                                      {state().code.slice(0, 3)} {state().code.slice(3)}1122                                    </span>1123                                  </p>1124                                </div>1125                              </div>1126                              <div class="peach-totp-meta">1127                                <span class="peach-label">expires</span>1128                                <div class="peach-totp-countdown">{state().remaining}s</div>1129                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1130                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1131                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1132                                    stroke-dasharray="106.8"1133                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1134                                  />1135                                </svg>1136                              </div>1137                            </>1138                          )}1139                        </Show>1140                      }1141                    >1142                      <div1143                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1144                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1145                      >1146                        <span class="peach-label">2-factor</span>1147                        <input1148                          class="peach-inline-input"1149                          value={draft()}1150                          onInput={(e) => setDraft(e.currentTarget.value)}1151                          onKeyDown={onEditKeyDown}1152                          ref={autoFocus}1153                          disabled={saving()}1154                          placeholder="otpauth://... or base32 secret"1155                          autocomplete="off"1156                        />1157                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1158                      </div>1159                    </Show>1160                  </section>1161                </Show>1162              </Show>11631164              {/* ── Advanced (canary + passkey) ───────────────────── */}1165              <Show when={entry().type === "login"}>1166                <button1167                  class="peach-add-field-link peach-entry-add-link"1168                  onClick={() => setShowAdvanced((v) => !v)}1169                  aria-expanded={showAdvanced()}1170                >1171                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1172                </button>1173                <Show when={showAdvanced()}>1174                  <Show when={!isPasskeyOnly(entry())}>1175                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1176                  </Show>11771178                  <Show when={entry().passkey}>1179                    {(passkey) => {1180                      const currentBrowser = detectBrowser();1181                      const crossBrowser =1182                        passkey().browser &&1183                        currentBrowser !== "unknown" &&1184                        passkey().browser !== currentBrowser;1185                      const algorithmLabel =1186                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1187                      const formatDate = (ts?: number) =>1188                        ts ? new Date(ts).toLocaleDateString() : "Never";1189                      return (1190                        <section1191                          class="peach-fields peach-passkey-section"1192                          style="position:relative;z-index:1;margin-top:6px;"1193                        >1194                          <div class="peach-field-card">1195                            <span class="peach-label">passkey · {passkey().rpId}</span>1196                            <div class="peach-field-value-row">1197                              <span class="peach-field-value">{passkey().userName}</span>1198                              <span class="peach-passkey-badge">passkey</span>1199                            </div>1200                          </div>12011202                          <div class="peach-field-card">1203                            <span class="peach-label">algorithm</span>1204                            <div class="peach-field-value-row">1205                              <span class="peach-field-value">{algorithmLabel}</span>1206                            </div>1207                          </div>12081209                          <div class="peach-field-card">1210                            <span class="peach-label">activity</span>1211                            <div class="peach-field-value-row peach-passkey-activity">1212                              <span class="peach-field-value">1213                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1214                              </span>1215                              <span class="peach-passkey-meta">1216                                created {formatDate(passkey().createdAt)}1217                              </span>1218                              <span class="peach-passkey-meta">1219                                last used {formatDate(passkey().lastUsedAt)}1220                              </span>1221                            </div>1222                          </div>12231224                          <Show when={crossBrowser}>1225                            <div class="peach-info-notice peach-info-notice--warn">1226                              {`This passkey was created on ${1227                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1228                              }. To use it on ${1229                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1230                              }, re-register it on this device.`}1231                            </div>1232                          </Show>12331234                          <div class="peach-info-notice">1235                            {passkey().browser === "firefox"1236                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1237                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1238                          </div>12391240                          <button1241                            class="peach-add-field-link peach-entry-add-link"1242                            style="color:rgba(220,38,38,0.7);"1243                            onClick={async () => {1244                              const id = entry().id;1245                              await sendMessage({1246                                type: "UPDATE_ENTRY",1247                                payload: { id, patch: { passkey: undefined } },1248                              });1249                              await props.onFieldSaved();1250                            }}1251                          >1252                            − remove passkey1253                          </button>1254                        </section>1255                      );1256                    }}1257                  </Show>1258                </Show>1259              </Show>12601261              {/* ── Bottom spacer ─────────────────────────────────── */}1262              <div style="height:4px;" />1263            </>1264          )}1265        </Show>1266      </div>12671268    </>1269  );1270}12711272// ── Sub-components ────────────────────────────────────────────────────12731274function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1275  return (1276    <div class="peach-inline-actions">1277      <button1278        class="peach-inline-save-btn"1279        onClick={props.onSave}1280        disabled={props.saving}1281        aria-label="Save"1282        title="Save (Enter)"1283      >1284        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1285          <path d="M1 4.5L4 7.5L10 1.5" />1286        </svg>1287      </button>1288      <button1289        class="peach-inline-cancel-btn"1290        onClick={props.onCancel}1291        disabled={props.saving}1292        aria-label="Cancel"1293        title="Cancel (Esc)"1294      >1295        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1296          <path d="M1 1L8 8M8 1L1 8" />1297        </svg>1298      </button>1299    </div>1300  );1301}13021303function EmptyVaultState() {1304  return (1305    <>1306      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1307        <div class="peach-entry-name">1308          <span class="peach-entry-name-text">1309            <span class="peach-entry-name-char" style="--char-index:0">e</span>1310            <span class="peach-entry-name-char" style="--char-index:1">m</span>1311            <span class="peach-entry-name-char" style="--char-index:2">p</span>1312            <span class="peach-entry-name-char" style="--char-index:3">t</span>1313            <span class="peach-entry-name-char" style="--char-index:4">y</span>1314          </span>1315        </div>1316      </div>1317      <div class="peach-empty-copy">1318        <span class="peach-label">vault</span>1319        <p>Your vault is ready for its first entry.</p>1320      </div>1321    </>1322  );1323}13241325function CopyGlyph() {1326  return (1327    <span class="peach-copy-icon">1328      <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">1329        <rect x="4" y="4" width="8" height="9" rx="1.5" />1330        <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" />1331      </svg>1332    </span>1333  );1334}13351336function EyeClosedGlyph() {1337  return (1338    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1339      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1340      <path d="M3.5 5.5L2.5 7.5" />1341      <path d="M7 6.5V8.5" />1342      <path d="M10.5 5.5L11.5 7.5" />1343    </svg>1344  );1345}13461347function EyeOpenGlyph() {1348  return (1349    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1350      <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" />1351      <circle cx="7" cy="5" r="1.75" />1352    </svg>1353  );1354}13551356function ChevronGlyph(props: { open: boolean }) {1357  return (1358    <svg1359      width="8"1360      height="8"1361      viewBox="0 0 8 8"1362      fill="none"1363      stroke="currentColor"1364      stroke-width="1.3"1365      stroke-linecap="round"1366      stroke-linejoin="round"1367      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1368    >1369      <path d="M2 1L6 4L2 7" />1370    </svg>1371  );1372}13731374function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1375  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1376  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1377  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1378  const [testResult, setTestResult] = createSignal<string | null>(null);1379  const [testing, setTesting] = createSignal(false);13801381  async function save() {1382    const url = webhookUrl().trim();1383    const nextLabel = label().trim() || undefined;1384    const canary = enabled() && url1385      ? {1386          webhookUrl: url,1387          label: nextLabel,1388          lastPinged: props.entry.canary?.lastPinged,1389          pingCount: props.entry.canary?.pingCount,1390        }1391      : undefined;1392    await sendMessage({1393      type: "UPDATE_ENTRY",1394      payload: { id: props.entry.id, patch: { canary } },1395    });1396    await props.onSaved();1397  }13981399  async function testPing() {1400    const url = webhookUrl().trim();1401    if (!url) return;1402    setTesting(true);1403    setTestResult(null);1404    const res = await sendMessage({1405      type: "FIRE_CANARY_PING",1406      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1407    }) as { ok: boolean; error?: string };1408    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1409    setTesting(false);1410  }14111412  return (1413    <div class="peach-canary-section">1414      <div class="peach-field-value-row">1415        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1416        <button1417          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1418          onClick={() => { setEnabled(!enabled()); void save(); }}1419          aria-label="Toggle canary"1420        >1421          <span class="peach-toggle-knob" />1422        </button>1423      </div>1424      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1425        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1426      </p>1427      <Show when={enabled()}>1428        <div class="peach-inline-field-wrap" style="margin-top:8px;">1429          <input1430            class="peach-inline-input"1431            placeholder="https://hooks.zapier.com/..."1432            value={webhookUrl()}1433            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1434            onBlur={() => void save()}1435          />1436          <input1437            class="peach-inline-input"1438            placeholder="label (optional, e.g. AWS decoy)"1439            value={label()}1440            onInput={(e) => setLabel(e.currentTarget.value)}1441            onBlur={() => void save()}1442          />1443          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1444            <button1445              class="peach-btn peach-btn--ghost peach-btn--sm"1446              onClick={() => void testPing()}1447              disabled={testing() || !webhookUrl().trim()}1448            >1449              {testing() ? "sending..." : "test ping"}1450            </button>1451            <Show when={testResult()}>1452              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1453            </Show>1454          </div>1455          <Show when={props.entry.canary?.lastPinged}>1456            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1457              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1458              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1459            </p>1460          </Show>1461        </div>1462      </Show>1463    </div>1464  );1465}