Entry.tsx

35.1 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  // ── Inline editing ────────────────────────────────────────────────6364  function startEdit(field: EditableField, value: string) {65    setEditingField(field);66    setDraft(value);67    setShowGenerator(false);68    setConfirmDelete(false);69  }7071  async function saveField() {72    const field = editingField();73    const entry = props.entry;74    if (!field || !entry) return;75    // Don't trim passwords — spaces may be intentional76    const value = field === "password" ? draft() : draft().trim();77    if (field === "password" && entry.type !== "note" && !value) {78      return;79    }80    setSaving(true);81    try {82      await sendMessage({83        type: "UPDATE_ENTRY",84        payload: { id: entry.id, patch: { [field]: value } },85      });86      triggerRing();87      setEditingField(null);88      setShowGenerator(false);89      await props.onFieldSaved();90    } catch {91      // silent — leave editing open92    } finally {93      setSaving(false);94    }95  }9697  function cancelEdit() {98    setEditingField(null);99    setDraft("");100    setShowGenerator(false);101  }102103  async function savePassword(password: string) {104    const entry = props.entry;105    if (!entry) return;106    setSaving(true);107    try {108      await sendMessage({109        type: "UPDATE_ENTRY",110        payload: { id: entry.id, patch: { password } },111      });112      triggerRing();113      setShowGenerator(false);114      await props.onFieldSaved();115    } catch {116      // silent117    } finally {118      setSaving(false);119    }120  }121122  // ── Copy ─────────────────────────────────────────────────────────123124  async function copyField(text: string, label: string, button?: HTMLButtonElement) {125    const response = await sendMessage({ type: "COPY", payload: { text, label } });126    if ((response as { ok: boolean }).ok) {127      const entry = props.entry;128      if (entry?.canary?.webhookUrl) {129        const trigger =130          label === "password"131            ? "password_copied"132            : label === "username"133              ? "username_copied"134              : undefined;135        if (trigger) {136          void sendMessage({137            type: "FIRE_CANARY_PING",138            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },139          });140        }141      }142      triggerRing();143      triggerPipPulse();144      setCopied(null);145      requestAnimationFrame(() => setCopied(label));146      if (button) {147        button.classList.remove("is-copied", "is-retreat");148        void button.offsetWidth;149        const drain = document.createElement("span");150        drain.className = "peach-copy-drain";151        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";152        drain.appendChild(bar);153        button.appendChild(drain);154        window.setTimeout(() => {155          button.classList.remove("is-copied");156          button.classList.add("is-retreat");157          drain.remove();158          window.setTimeout(() => button.classList.remove("is-retreat"), 160);159        }, 1800);160      }161      window.setTimeout(() => setCopied(null), 1800);162    }163  }164165  // ── Keyboard shortcuts ────────────────────────────────────────────166167  createEffect(() => {168    if (!props.entry) return;169    const handler = (e: KeyboardEvent) => {170      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;171      if (inInput) return;172      switch (e.key) {173        case "c": e.preventDefault();174          if (props.entry?.password) void copyField(props.entry.password, "password"); break;175        case "u": e.preventDefault();176          if (props.entry?.username) void copyField(props.entry.username, "username"); break;177        case "Escape": e.preventDefault(); props.onEscape?.(); break;178      }179    };180    window.addEventListener("keydown", handler);181    onCleanup(() => window.removeEventListener("keydown", handler));182  });183184  // ── Input ref focus helper ────────────────────────────────────────185186  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {187    requestAnimationFrame(() => { el.focus(); el.select?.(); });188  }189190  function onEditKeyDown(e: KeyboardEvent) {191    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {192      e.preventDefault();193      void saveField();194    }195    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }196  }197198  return (199    <>200      <div201        class="peach-ghost"202        aria-hidden="true"203        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;"204      >205        {props.entry?.title || "empty"}206      </div>207208      <div class="peach-stage" style="position:relative;">209        <Show when={props.entry} fallback={<EmptyVaultState />}>210          {(entry) => (211            <>212              {/* ── Breach strip ─────────────────────────────────── */}213              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>214                <div class="peach-breach-badge">215                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">216                    <path d="M6 1L11 10H1L6 1z" />217                    <line x1="6" y1="5" x2="6" y2="7.5" />218                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />219                  </svg>220                  <span style="flex:1">found in a known data breach</span>221                  <button222                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"223                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}224                  >225                    Change password226                  </button>227                </div>228              </Show>229230              {/* ── Title + trash ─────────────────────────────────── */}231              <div class="peach-entry-header">232                <div class="peach-entry-header-name">233                  <Show234                    when={editingField() === "title"}235                    fallback={236                      <button237                        class="peach-entry-name-button"238                        title="Click to edit title"239                        onClick={() => startEdit("title", entry().title)}240                      >241                        <h1 class="peach-entry-name" aria-label={entry().title}>242                          <span class="peach-entry-name-text">243                            <For each={entry().title.split("")}>244                              {(ch, i) => (245                                <span class="peach-entry-name-char" style={`--char-index:${i()}`}>246                                  {ch === " " ? "\u00A0" : ch}247                                </span>248                              )}249                            </For>250                          </span>251                        </h1>252                      </button>253                    }254                  >255                    <div class="peach-inline-title-wrap">256                      <input257                        class="peach-inline-title-input"258                        value={draft()}259                        onInput={(e) => setDraft(e.currentTarget.value)}260                        onKeyDown={onEditKeyDown}261                        ref={autoFocus}262                        disabled={saving()}263                      />264                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />265                    </div>266                  </Show>267                </div>268269                {/* Subtle trash icon — hidden while editing title */}270                <Show when={editingField() !== "title"}>271                  <Show272                    when={confirmDelete()}273                    fallback={274                      <button275                        class="peach-entry-trash-btn"276                        onClick={() => setConfirmDelete(true)}277                        aria-label="Move to trash"278                        title="Delete entry"279                      >280                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">281                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />282                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />283                        </svg>284                      </button>285                    }286                  >287                    <div class="peach-entry-trash-confirm">288                      <span class="peach-entry-trash-confirm-label">trash?</span>289                      <button290                        class="peach-btn peach-btn--sm peach-btn--danger"291                        style="font-size:10px;padding:2px 8px;min-height:22px;"292                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}293                      >294                        yes295                      </button>296                      <button297                        class="peach-btn peach-btn--ghost peach-btn--sm"298                        style="font-size:10px;padding:2px 8px;min-height:22px;"299                        onClick={() => setConfirmDelete(false)}300                      >301                        no302                      </button>303                    </div>304                  </Show>305                </Show>306              </div>307308              {/* ── Domain / URL ─────────────────────────────────── */}309              <Show when={entry().type !== "note"}>310                <div class="peach-domain" style="position:relative;z-index:1;">311                  <Show312                    when={editingField() === "url"}313                    fallback={314                      <div315                        class="peach-domain-row peach-inline-clickable"316                        title="Click to edit URL"317                        onClick={() => startEdit("url", entry().url ?? "")}318                      >319                        <Show when={faviconSrc()}>320                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />321                        </Show>322                        <span class="peach-domain-value">323                          {displayDomain() || (entry().url ? entry().url : "add a website url")}324                        </span>325                        <span class="peach-inline-edit-hint">edit</span>326                      </div>327                    }328                  >329                    <div class="peach-inline-field-wrap">330                      <input331                        class="peach-inline-input"332                        type="url"333                        placeholder="https://"334                        value={draft()}335                        onInput={(e) => setDraft(e.currentTarget.value)}336                        onKeyDown={onEditKeyDown}337                        ref={autoFocus}338                        disabled={saving()}339                      />340                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />341                    </div>342                  </Show>343                </div>344              </Show>345346              {/* ── Tags ─────────────────────────────────────────── */}347              <Show when={entry().tags && entry().tags!.length > 0}>348                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">349                  <For each={entry().tags!}>350                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}351                  </For>352                </div>353              </Show>354355              {/* ── Note content ─────────────────────────────────── */}356              <Show when={entry().type === "note"}>357                <section class="peach-fields" style="position:relative;z-index:1;">358                  <Show359                    when={editingField() === "notes"}360                    fallback={361                      <div362                        class="peach-note-content peach-inline-clickable"363                        style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"364                        title="Click to edit"365                        onClick={() => startEdit("notes", entry().notes ?? "")}366                      >367                        {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}368                      </div>369                    }370                  >371                    <div class="peach-inline-field-wrap">372                      <textarea373                        class="peach-inline-textarea"374                        value={draft()}375                        onInput={(e) => setDraft(e.currentTarget.value)}376                        onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}377                        ref={(el) => requestAnimationFrame(() => el?.focus())}378                        rows={6}379                        disabled={saving()}380                      />381                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />382                    </div>383                  </Show>384                </section>385              </Show>386387              {/* ── Login fields ─────────────────────────────────── */}388              <Show when={entry().type !== "note"}>389                <section class="peach-fields" style="position:relative;z-index:1;">390391                  {/* Username */}392                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>393                    <span class="peach-label">username</span>394                    <Show395                      when={editingField() === "username"}396                      fallback={397                        <div class="peach-field-value-row">398                          <span399                            class="peach-field-value peach-inline-clickable"400                            title="Click to edit"401                            onClick={() => startEdit("username", entry().username ?? "")}402                          >403                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}404                          </span>405                          <Show when={entry().username}>406                            <button407                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}408                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}409                              aria-label="Copy username"410                            >411                              <CopyGlyph /><span class="peach-copy-sheen" />412                            </button>413                          </Show>414                        </div>415                      }416                    >417                      <div class="peach-inline-field-wrap">418                        <input419                          class="peach-inline-input"420                          value={draft()}421                          onInput={(e) => setDraft(e.currentTarget.value)}422                          onKeyDown={onEditKeyDown}423                          ref={autoFocus}424                          disabled={saving()}425                          autocomplete="off"426                        />427                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />428                      </div>429                    </Show>430                  </div>431432                  {/* Password */}433                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>434                    <span class="peach-label">password</span>435                    <Show436                      when={editingField() === "password"}437                      fallback={438                        <div class="peach-field-value-row">439                          <span440                            class="peach-password-value"441                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}442                            style="cursor:pointer;"443                            title="Click to edit password"444                            onClick={() => startEdit("password", entry().password ?? "")}445                          >446                            <Show447                              when={showPassword()}448                              fallback={<span class="peach-password-mask">...............</span>}449                            >450                              <span class="peach-password-text">{entry().password ?? ""}</span>451                            </Show>452                          </span>453                          <div class="peach-field-actions">454                            <button455                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"456                              onClick={() => setShowPassword(!showPassword())}457                              aria-label="Reveal password"458                            >459                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>460                                <EyeClosedGlyph />461                              </Show>462                            </button>463                            <button464                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}465                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}466                              aria-label="Copy password"467                            >468                              <CopyGlyph /><span class="peach-copy-sheen" />469                            </button>470                          </div>471                        </div>472                      }473                    >474                      {/* ── Edit mode: type / paste / generate ── */}475                      <div class="peach-inline-field-wrap">476                        <div class="peach-password-edit-row">477                          <input478                            class="peach-inline-input"479                            type={showPassword() ? "text" : "password"}480                            placeholder="paste or type a password"481                            value={draft()}482                            onInput={(e) => setDraft(e.currentTarget.value)}483                            onKeyDown={(e) => {484                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }485                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }486                            }}487                            ref={autoFocus}488                            disabled={saving()}489                            autocomplete="new-password"490                          />491                          <button492                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"493                            type="button"494                            onClick={() => setShowPassword(!showPassword())}495                            aria-label={showPassword() ? "Hide" : "Show"}496                          >497                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>498                              <EyeClosedGlyph />499                            </Show>500                          </button>501                        </div>502                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />503                        <button504                          class="peach-generate-toggle-link"505                          type="button"506                          onClick={() => setShowGenerator(!showGenerator())}507                        >508                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">509                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />510                            <path d="M9.5 2.5H11V1" />511                          </svg>512                          {showGenerator() ? "hide generator" : "generate"}513                        </button>514                        <Show when={showGenerator()}>515                          <GeneratorDrawer516                            open={true}517                            compact={true}518                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}519                          />520                        </Show>521                      </div>522                    </Show>523                  </div>524525                </section>526              </Show>527528              {/* ── Notes (on login entries) ──────────────────────── */}529              <Show when={entry().type !== "note"}>530                <Show531                  when={entry().notes || editingField() === "notes"}532                  fallback={533                    <button534                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"535                      onClick={() => startEdit("notes", "")}536                    >537                      + add notes538                    </button>539                  }540                >541                  <div style="position:relative;z-index:1;margin-top:6px;">542                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>543                    <Show544                      when={editingField() === "notes"}545                      fallback={546                        <div547                          class="peach-note-content peach-inline-clickable"548                          style="cursor:text;white-space:pre-wrap;"549                          onClick={() => startEdit("notes", entry().notes ?? "")}550                        >551                          {entry().notes}552                        </div>553                      }554                    >555                      <div class="peach-inline-field-wrap">556                        <textarea557                          class="peach-inline-textarea"558                          value={draft()}559                          onInput={(e) => setDraft(e.currentTarget.value)}560                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}561                          ref={(el) => requestAnimationFrame(() => el?.focus())}562                          rows={4}563                          disabled={saving()}564                        />565                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />566                      </div>567                    </Show>568                  </div>569                </Show>570              </Show>571572              {/* ── TOTP ─────────────────────────────────────────── */}573              <Show when={totpState()}>574                {(state) => (575                  <section class="peach-totp-band">576                    <div class="peach-totp-main">577                      <span class="peach-label">2-factor</span>578                      <div class="peach-totp-code-row">579                        <p class="peach-totp-code">580                          <span class="peach-totp-code-text">581                            {state().code.slice(0, 3)} {state().code.slice(3)}582                          </span>583                        </p>584                        <button585                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}586                          onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}587                          aria-label="Copy TOTP code"588                        >589                          <CopyGlyph /><span class="peach-copy-sheen" />590                        </button>591                      </div>592                    </div>593                    <div class="peach-totp-meta">594                      <span class="peach-label">expires</span>595                      <div class="peach-totp-countdown">{state().remaining}s</div>596                      <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">597                        <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />598                        <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"599                          stroke-dasharray="106.8"600                          stroke-dashoffset={(1 - state().remaining / 30) * 106.8}601                        />602                      </svg>603                    </div>604                    <div class="peach-totp-progress">605                      <div class="peach-totp-progress-bar" style={`width:${Math.max(0, totpProgress())}%`} />606                    </div>607                  </section>608                )}609              </Show>610611              {/* ── Bottom spacer ─────────────────────────────────── */}612              <div style="height:4px;" />613            </>614          )}615        </Show>616      </div>617    </>618  );619}620621// ── Sub-components ────────────────────────────────────────────────────622623function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {624  return (625    <div class="peach-inline-actions">626      <button627        class="peach-inline-save-btn"628        onClick={props.onSave}629        disabled={props.saving}630        aria-label="Save"631        title="Save (Enter)"632      >633        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">634          <path d="M1 4.5L4 7.5L10 1.5" />635        </svg>636      </button>637      <button638        class="peach-inline-cancel-btn"639        onClick={props.onCancel}640        disabled={props.saving}641        aria-label="Cancel"642        title="Cancel (Esc)"643      >644        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">645          <path d="M1 1L8 8M8 1L1 8" />646        </svg>647      </button>648    </div>649  );650}651652function EmptyVaultState() {653  return (654    <>655      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">656        <div class="peach-entry-name">657          <span class="peach-entry-name-text">658            <span class="peach-entry-name-char" style="--char-index:0">e</span>659            <span class="peach-entry-name-char" style="--char-index:1">m</span>660            <span class="peach-entry-name-char" style="--char-index:2">p</span>661            <span class="peach-entry-name-char" style="--char-index:3">t</span>662            <span class="peach-entry-name-char" style="--char-index:4">y</span>663          </span>664        </div>665      </div>666      <div class="peach-empty-copy">667        <span class="peach-label">vault</span>668        <p>Your vault is ready for its first entry.</p>669      </div>670    </>671  );672}673674function CopyGlyph() {675  return (676    <span class="peach-copy-icon">677      <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">678        <rect x="4" y="4" width="8" height="9" rx="1.5" />679        <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" />680      </svg>681    </span>682  );683}684685function EyeClosedGlyph() {686  return (687    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">688      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />689      <path d="M3.5 5.5L2.5 7.5" />690      <path d="M7 6.5V8.5" />691      <path d="M10.5 5.5L11.5 7.5" />692    </svg>693  );694}695696function EyeOpenGlyph() {697  return (698    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">699      <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" />700      <circle cx="7" cy="5" r="1.75" />701    </svg>702  );703}704705function ChevronGlyph(props: { open: boolean }) {706  return (707    <svg708      width="8"709      height="8"710      viewBox="0 0 8 8"711      fill="none"712      stroke="currentColor"713      stroke-width="1.3"714      stroke-linecap="round"715      stroke-linejoin="round"716      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}717    >718      <path d="M2 1L6 4L2 7" />719    </svg>720  );721}722723function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {724  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));725  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");726  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");727  const [testResult, setTestResult] = createSignal<string | null>(null);728  const [testing, setTesting] = createSignal(false);729730  async function save() {731    const url = webhookUrl().trim();732    const nextLabel = label().trim() || undefined;733    const canary = enabled() && url734      ? {735          webhookUrl: url,736          label: nextLabel,737          lastPinged: props.entry.canary?.lastPinged,738          pingCount: props.entry.canary?.pingCount,739        }740      : undefined;741    await sendMessage({742      type: "UPDATE_ENTRY",743      payload: { id: props.entry.id, patch: { canary } },744    });745    await props.onSaved();746  }747748  async function testPing() {749    const url = webhookUrl().trim();750    if (!url) return;751    setTesting(true);752    setTestResult(null);753    const res = await sendMessage({754      type: "FIRE_CANARY_PING",755      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },756    }) as { ok: boolean; error?: string };757    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);758    setTesting(false);759  }760761  return (762    <div class="peach-canary-section">763      <div class="peach-field-value-row">764        <span class="peach-field-value" style="font-size:12px;">canary alert</span>765        <button766          class={`peach-toggle ${enabled() ? "is-on" : ""}`}767          onClick={() => { setEnabled(!enabled()); void save(); }}768          aria-label="Toggle canary"769        >770          <span class="peach-toggle-knob" />771        </button>772      </div>773      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">774        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.775      </p>776      <Show when={enabled()}>777        <div class="peach-inline-field-wrap" style="margin-top:8px;">778          <input779            class="peach-inline-input"780            placeholder="https://hooks.zapier.com/..."781            value={webhookUrl()}782            onInput={(e) => setWebhookUrl(e.currentTarget.value)}783            onBlur={() => void save()}784          />785          <input786            class="peach-inline-input"787            placeholder="label (optional, e.g. AWS decoy)"788            value={label()}789            onInput={(e) => setLabel(e.currentTarget.value)}790            onBlur={() => void save()}791          />792          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">793            <button794              class="peach-btn peach-btn--ghost peach-btn--sm"795              onClick={() => void testPing()}796              disabled={testing() || !webhookUrl().trim()}797            >798              {testing() ? "sending..." : "test ping"}799            </button>800            <Show when={testResult()}>801              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>802            </Show>803          </div>804          <Show when={props.entry.canary?.lastPinged}>805            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">806              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}807              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts808            </p>809          </Show>810        </div>811      </Show>812    </div>813  );814}