Entry.tsx

37.8 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, triggerTotpRing } 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) triggerTotpRing();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  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {136    switch (field) {137      case "title":138        return entry.title;139      case "url":140        return entry.url ?? "";141      case "username":142        return entry.username ?? "";143      case "password":144        return entry.password ?? "";145      case "notes":146        return entry.notes ?? "";147      case "totp":148        return entry.totp ?? "";149    }150  }151152  function handleEditorFocusOut(153    field: Exclude<EditableField, null>,154    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }155  ) {156    const nextTarget = event.relatedTarget;157    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;158159    const entry = props.entry;160    if (!entry) return;161162    const currentValue = fieldValue(entry, field);163    const nextValue = field === "password" ? draft() : draft().trim();164    if (nextValue === currentValue) {165      cancelEdit();166    }167  }168169  async function savePassword(password: string) {170    const entry = props.entry;171    if (!entry) return;172    setSaving(true);173    try {174      await sendMessage({175        type: "UPDATE_ENTRY",176        payload: { id: entry.id, patch: { password } },177      });178      triggerRing();179      setShowGenerator(false);180      await props.onFieldSaved();181    } catch {182      // silent183    } finally {184      setSaving(false);185    }186  }187188  // ── Copy ─────────────────────────────────────────────────────────189190  async function copyField(text: string, label: string, button?: HTMLButtonElement) {191    const response = await sendMessage({ type: "COPY", payload: { text, label } });192    if ((response as { ok: boolean }).ok) {193      const entry = props.entry;194      if (entry?.canary?.webhookUrl) {195        const trigger =196          label === "password"197            ? "password_copied"198            : label === "username"199              ? "username_copied"200              : undefined;201        if (trigger) {202          void sendMessage({203            type: "FIRE_CANARY_PING",204            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },205          });206        }207      }208      triggerRing();209      triggerPipPulse();210      setCopied(null);211      requestAnimationFrame(() => setCopied(label));212      if (button) {213        button.classList.remove("is-copied", "is-retreat");214        void button.offsetWidth;215        const drain = document.createElement("span");216        drain.className = "peach-copy-drain";217        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";218        drain.appendChild(bar);219        button.appendChild(drain);220        window.setTimeout(() => {221          button.classList.remove("is-copied");222          button.classList.add("is-retreat");223          drain.remove();224          window.setTimeout(() => button.classList.remove("is-retreat"), 160);225        }, 1800);226      }227      window.setTimeout(() => setCopied(null), 1800);228    }229  }230231  // ── Keyboard shortcuts ────────────────────────────────────────────232233  createEffect(() => {234    if (!props.entry) return;235    const handler = (e: KeyboardEvent) => {236      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;237      if (inInput) return;238      switch (e.key) {239        case "c": e.preventDefault();240          if (props.entry?.password) void copyField(props.entry.password, "password"); break;241        case "u": e.preventDefault();242          if (props.entry?.username) void copyField(props.entry.username, "username"); break;243        case "Escape": e.preventDefault(); props.onEscape?.(); break;244      }245    };246    window.addEventListener("keydown", handler);247    onCleanup(() => window.removeEventListener("keydown", handler));248  });249250  // ── Input ref focus helper ────────────────────────────────────────251252  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {253    requestAnimationFrame(() => { el.focus(); el.select?.(); });254  }255256  function onEditKeyDown(e: KeyboardEvent) {257    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {258      e.preventDefault();259      void saveField();260    }261    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }262  }263264  return (265    <>266      <div267        class="peach-ghost"268        aria-hidden="true"269        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;"270      >271        {props.entry?.title || "empty"}272      </div>273274      <div class="peach-stage" style="position:relative;">275        <Show when={props.entry} fallback={<EmptyVaultState />}>276          {(entry) => (277            <>278              {/* ── Breach strip ─────────────────────────────────── */}279              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>280                <div class="peach-breach-badge">281                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">282                    <path d="M6 1L11 10H1L6 1z" />283                    <line x1="6" y1="5" x2="6" y2="7.5" />284                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />285                  </svg>286                  <span style="flex:1">found in a known data breach</span>287                  <button288                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"289                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}290                  >291                    Change password292                  </button>293                </div>294              </Show>295296              {/* ── Title + trash ─────────────────────────────────── */}297              <div class="peach-entry-header">298                <div class="peach-entry-header-name">299                  <Show300                    when={editingField() === "title"}301                    fallback={302                      <button303                        class="peach-entry-name-button"304                        title="Click to edit title"305                        onClick={() => startEdit("title", entry().title)}306                      >307                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>308                          <span class="peach-entry-name-text">309                            <Show310                              when={useAnimatedTitle()}311                              fallback={entry().title}312                            >313                              <For each={entry().title.split("")}>314                                {(ch, i) => (315                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>316                                    {ch === " " ? "\u00A0" : ch}317                                  </span>318                                )}319                              </For>320                            </Show>321                          </span>322                        </h1>323                      </button>324                    }325                  >326                    <div327                      class="peach-inline-title-wrap"328                      onFocusOut={(event) => handleEditorFocusOut("title", event)}329                    >330                      <input331                        class="peach-inline-title-input"332                        value={draft()}333                        onInput={(e) => setDraft(e.currentTarget.value)}334                        onKeyDown={onEditKeyDown}335                        ref={autoFocus}336                        disabled={saving()}337                      />338                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />339                    </div>340                  </Show>341                </div>342343                {/* Subtle trash icon — hidden while editing title */}344                <Show when={editingField() !== "title"}>345                  <Show346                    when={confirmDelete()}347                    fallback={348                      <button349                        class="peach-entry-trash-btn"350                        onClick={() => setConfirmDelete(true)}351                        aria-label="Move to trash"352                        title="Delete entry"353                      >354                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">355                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />356                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />357                        </svg>358                      </button>359                    }360                  >361                    <div class="peach-entry-trash-confirm">362                      <span class="peach-entry-trash-confirm-label">trash?</span>363                      <button364                        class="peach-btn peach-btn--sm peach-btn--danger"365                        style="font-size:10px;padding:2px 8px;min-height:22px;"366                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}367                      >368                        yes369                      </button>370                      <button371                        class="peach-btn peach-btn--ghost peach-btn--sm"372                        style="font-size:10px;padding:2px 8px;min-height:22px;"373                        onClick={() => setConfirmDelete(false)}374                      >375                        no376                      </button>377                    </div>378                  </Show>379                </Show>380              </div>381382              {/* ── Domain / URL ─────────────────────────────────── */}383              <Show when={entry().type !== "note"}>384                <div class="peach-domain" style="position:relative;z-index:1;">385                  <Show386                    when={editingField() === "url"}387                    fallback={388                      <div389                        class="peach-domain-row peach-inline-clickable"390                        title="Click to edit URL"391                        onClick={() => startEdit("url", entry().url ?? "")}392                      >393                        <Show when={faviconSrc()}>394                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />395                        </Show>396                        <span class="peach-domain-value">397                          {displayDomain() || (entry().url ? entry().url : "add a website url")}398                        </span>399                        <span class="peach-inline-edit-hint">edit</span>400                      </div>401                    }402                  >403                    <div404                      class="peach-inline-field-wrap"405                      onFocusOut={(event) => handleEditorFocusOut("url", event)}406                    >407                      <input408                        class="peach-inline-input"409                        type="url"410                        placeholder="https://"411                        value={draft()}412                        onInput={(e) => setDraft(e.currentTarget.value)}413                        onKeyDown={onEditKeyDown}414                        ref={autoFocus}415                        disabled={saving()}416                      />417                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />418                    </div>419                  </Show>420                </div>421              </Show>422423              {/* ── Tags ─────────────────────────────────────────── */}424              <Show when={entry().tags && entry().tags!.length > 0}>425                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">426                  <For each={entry().tags!}>427                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}428                  </For>429                </div>430              </Show>431432              {/* ── Note content ─────────────────────────────────── */}433              <Show when={entry().type === "note"}>434                <section class="peach-fields" style="position:relative;z-index:1;">435                  <Show436                    when={editingField() === "notes"}437                    fallback={438                      <div439                        class="peach-note-content peach-inline-clickable"440                        style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"441                        title="Click to edit"442                        onClick={() => startEdit("notes", entry().notes ?? "")}443                      >444                        {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}445                      </div>446                    }447                  >448                    <div449                      class="peach-inline-field-wrap"450                      onFocusOut={(event) => handleEditorFocusOut("notes", event)}451                    >452                      <textarea453                        class="peach-inline-textarea"454                        value={draft()}455                        onInput={(e) => setDraft(e.currentTarget.value)}456                        onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}457                        ref={(el) => requestAnimationFrame(() => el?.focus())}458                        rows={6}459                        disabled={saving()}460                      />461                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />462                    </div>463                  </Show>464                </section>465              </Show>466467              {/* ── Login fields ─────────────────────────────────── */}468              <Show when={entry().type !== "note"}>469                <section class="peach-fields" style="position:relative;z-index:1;">470471                  {/* Username */}472                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>473                    <span class="peach-label">username</span>474                    <Show475                      when={editingField() === "username"}476                      fallback={477                        <div class="peach-field-value-row">478                          <span479                            class="peach-field-value peach-inline-clickable"480                            title="Click to edit"481                            onClick={() => startEdit("username", entry().username ?? "")}482                          >483                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}484                          </span>485                          <Show when={entry().username}>486                            <button487                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}488                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}489                              aria-label="Copy username"490                            >491                              <CopyGlyph /><span class="peach-copy-sheen" />492                            </button>493                          </Show>494                        </div>495                      }496                    >497                      <div498                        class="peach-inline-field-wrap"499                        onFocusOut={(event) => handleEditorFocusOut("username", event)}500                      >501                        <input502                          class="peach-inline-input"503                          value={draft()}504                          onInput={(e) => setDraft(e.currentTarget.value)}505                          onKeyDown={onEditKeyDown}506                          ref={autoFocus}507                          disabled={saving()}508                          autocomplete="off"509                        />510                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />511                      </div>512                    </Show>513                  </div>514515                  {/* Password */}516                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>517                    <span class="peach-label">password</span>518                    <Show519                      when={editingField() === "password"}520                      fallback={521                        <div class="peach-field-value-row">522                          <span523                            class="peach-password-value"524                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}525                            style="cursor:pointer;"526                            title="Click to edit password"527                            onClick={() => startEdit("password", entry().password ?? "")}528                          >529                            <Show530                              when={showPassword()}531                              fallback={<span class="peach-password-mask">...............</span>}532                            >533                              <span class="peach-password-text">{entry().password ?? ""}</span>534                            </Show>535                          </span>536                          <div class="peach-field-actions">537                            <button538                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"539                              onClick={() => setShowPassword(!showPassword())}540                              aria-label="Reveal password"541                            >542                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>543                                <EyeClosedGlyph />544                              </Show>545                            </button>546                            <button547                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}548                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}549                              aria-label="Copy password"550                            >551                              <CopyGlyph /><span class="peach-copy-sheen" />552                            </button>553                          </div>554                        </div>555                      }556                    >557                      {/* ── Edit mode: type / paste / generate ── */}558                      <div559                        class="peach-inline-field-wrap"560                        onFocusOut={(event) => handleEditorFocusOut("password", event)}561                      >562                        <div class="peach-password-edit-row">563                          <input564                            class="peach-inline-input"565                            type={showPassword() ? "text" : "password"}566                            placeholder="paste or type a password"567                            value={draft()}568                            onInput={(e) => setDraft(e.currentTarget.value)}569                            onKeyDown={(e) => {570                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }571                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }572                            }}573                            ref={autoFocus}574                            disabled={saving()}575                            autocomplete="new-password"576                          />577                          <button578                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"579                            type="button"580                            onClick={() => setShowPassword(!showPassword())}581                            aria-label={showPassword() ? "Hide" : "Show"}582                          >583                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>584                              <EyeClosedGlyph />585                            </Show>586                          </button>587                        </div>588                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />589                        <button590                          class="peach-generate-toggle-link"591                          type="button"592                          onClick={() => setShowGenerator(!showGenerator())}593                        >594                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">595                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />596                            <path d="M9.5 2.5H11V1" />597                          </svg>598                          {showGenerator() ? "hide generator" : "generate"}599                        </button>600                        <Show when={showGenerator()}>601                          <GeneratorDrawer602                            open={true}603                            compact={true}604                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}605                          />606                        </Show>607                      </div>608                    </Show>609                  </div>610611                </section>612              </Show>613614              {/* ── Notes (on login entries) ──────────────────────── */}615              <Show when={entry().type !== "note"}>616                <Show617                  when={entry().notes || editingField() === "notes"}618                  fallback={619                    <button620                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"621                      onClick={() => startEdit("notes", "")}622                    >623                      + add notes624                    </button>625                  }626                >627                  <div style="position:relative;z-index:1;margin-top:6px;">628                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>629                    <Show630                      when={editingField() === "notes"}631                      fallback={632                        <div633                          class="peach-note-content peach-inline-clickable"634                          style="cursor:text;white-space:pre-wrap;"635                          onClick={() => startEdit("notes", entry().notes ?? "")}636                        >637                          {entry().notes}638                        </div>639                      }640                    >641                      <div642                        class="peach-inline-field-wrap"643                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}644                      >645                        <textarea646                          class="peach-inline-textarea"647                          value={draft()}648                          onInput={(e) => setDraft(e.currentTarget.value)}649                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}650                          ref={(el) => requestAnimationFrame(() => el?.focus())}651                          rows={4}652                          disabled={saving()}653                        />654                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />655                      </div>656                    </Show>657                  </div>658                </Show>659              </Show>660661              {/* ── TOTP ─────────────────────────────────────────── */}662              <Show when={totpState()}>663                {(state) => (664                  <section class="peach-totp-band">665                    <div class="peach-totp-main">666                      <span class="peach-label">2-factor</span>667                      <div class="peach-totp-code-row">668                        <p class="peach-totp-code">669                          <span class="peach-totp-code-text">670                            {state().code.slice(0, 3)} {state().code.slice(3)}671                          </span>672                        </p>673                        <button674                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}675                          onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}676                          aria-label="Copy TOTP code"677                        >678                          <CopyGlyph /><span class="peach-copy-sheen" />679                        </button>680                      </div>681                    </div>682                    <div class="peach-totp-meta">683                      <span class="peach-label">expires</span>684                      <div class="peach-totp-countdown">{state().remaining}s</div>685                      <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">686                        <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />687                        <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"688                          stroke-dasharray="106.8"689                          stroke-dashoffset={(1 - state().remaining / 30) * 106.8}690                        />691                      </svg>692                    </div>693                  </section>694                )}695              </Show>696697              {/* ── Bottom spacer ─────────────────────────────────── */}698              <div style="height:4px;" />699            </>700          )}701        </Show>702      </div>703    </>704  );705}706707// ── Sub-components ────────────────────────────────────────────────────708709function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {710  return (711    <div class="peach-inline-actions">712      <button713        class="peach-inline-save-btn"714        onClick={props.onSave}715        disabled={props.saving}716        aria-label="Save"717        title="Save (Enter)"718      >719        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">720          <path d="M1 4.5L4 7.5L10 1.5" />721        </svg>722      </button>723      <button724        class="peach-inline-cancel-btn"725        onClick={props.onCancel}726        disabled={props.saving}727        aria-label="Cancel"728        title="Cancel (Esc)"729      >730        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">731          <path d="M1 1L8 8M8 1L1 8" />732        </svg>733      </button>734    </div>735  );736}737738function EmptyVaultState() {739  return (740    <>741      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">742        <div class="peach-entry-name">743          <span class="peach-entry-name-text">744            <span class="peach-entry-name-char" style="--char-index:0">e</span>745            <span class="peach-entry-name-char" style="--char-index:1">m</span>746            <span class="peach-entry-name-char" style="--char-index:2">p</span>747            <span class="peach-entry-name-char" style="--char-index:3">t</span>748            <span class="peach-entry-name-char" style="--char-index:4">y</span>749          </span>750        </div>751      </div>752      <div class="peach-empty-copy">753        <span class="peach-label">vault</span>754        <p>Your vault is ready for its first entry.</p>755      </div>756    </>757  );758}759760function CopyGlyph() {761  return (762    <span class="peach-copy-icon">763      <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">764        <rect x="4" y="4" width="8" height="9" rx="1.5" />765        <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" />766      </svg>767    </span>768  );769}770771function EyeClosedGlyph() {772  return (773    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">774      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />775      <path d="M3.5 5.5L2.5 7.5" />776      <path d="M7 6.5V8.5" />777      <path d="M10.5 5.5L11.5 7.5" />778    </svg>779  );780}781782function EyeOpenGlyph() {783  return (784    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">785      <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" />786      <circle cx="7" cy="5" r="1.75" />787    </svg>788  );789}790791function ChevronGlyph(props: { open: boolean }) {792  return (793    <svg794      width="8"795      height="8"796      viewBox="0 0 8 8"797      fill="none"798      stroke="currentColor"799      stroke-width="1.3"800      stroke-linecap="round"801      stroke-linejoin="round"802      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}803    >804      <path d="M2 1L6 4L2 7" />805    </svg>806  );807}808809function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {810  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));811  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");812  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");813  const [testResult, setTestResult] = createSignal<string | null>(null);814  const [testing, setTesting] = createSignal(false);815816  async function save() {817    const url = webhookUrl().trim();818    const nextLabel = label().trim() || undefined;819    const canary = enabled() && url820      ? {821          webhookUrl: url,822          label: nextLabel,823          lastPinged: props.entry.canary?.lastPinged,824          pingCount: props.entry.canary?.pingCount,825        }826      : undefined;827    await sendMessage({828      type: "UPDATE_ENTRY",829      payload: { id: props.entry.id, patch: { canary } },830    });831    await props.onSaved();832  }833834  async function testPing() {835    const url = webhookUrl().trim();836    if (!url) return;837    setTesting(true);838    setTestResult(null);839    const res = await sendMessage({840      type: "FIRE_CANARY_PING",841      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },842    }) as { ok: boolean; error?: string };843    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);844    setTesting(false);845  }846847  return (848    <div class="peach-canary-section">849      <div class="peach-field-value-row">850        <span class="peach-field-value" style="font-size:12px;">canary alert</span>851        <button852          class={`peach-toggle ${enabled() ? "is-on" : ""}`}853          onClick={() => { setEnabled(!enabled()); void save(); }}854          aria-label="Toggle canary"855        >856          <span class="peach-toggle-knob" />857        </button>858      </div>859      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">860        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.861      </p>862      <Show when={enabled()}>863        <div class="peach-inline-field-wrap" style="margin-top:8px;">864          <input865            class="peach-inline-input"866            placeholder="https://hooks.zapier.com/..."867            value={webhookUrl()}868            onInput={(e) => setWebhookUrl(e.currentTarget.value)}869            onBlur={() => void save()}870          />871          <input872            class="peach-inline-input"873            placeholder="label (optional, e.g. AWS decoy)"874            value={label()}875            onInput={(e) => setLabel(e.currentTarget.value)}876            onBlur={() => void save()}877          />878          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">879            <button880              class="peach-btn peach-btn--ghost peach-btn--sm"881              onClick={() => void testPing()}882              disabled={testing() || !webhookUrl().trim()}883            >884              {testing() ? "sending..." : "test ping"}885            </button>886            <Show when={testResult()}>887              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>888            </Show>889          </div>890          <Show when={props.entry.canary?.lastPinged}>891            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">892              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}893              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts894            </p>895          </Show>896        </div>897      </Show>898    </div>899  );900}