Entry.tsx

36.0 KB · raw

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