Entry.tsx

68.4 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";1213type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1415interface EntryProps {16  entry: VaultEntry | null;17  /**18   * Current active tab's full URL (https only). Empty string when unavailable19   * — Capacitor, an extension page, or a chrome:///about: URL. The URL20   * editor uses this to render an "add current site" shortcut.21   */22  currentTabUrl?: string;23  onDelete: () => void;24  /** Called after any inline save so App can reload entries */25  onFieldSaved: () => void | Promise<void>;26  onEscape?: () => void;27}2829export function EntryView(props: EntryProps) {30  const [editingField, setEditingField] = createSignal<EditableField>(null);31  const [draft, setDraft] = createSignal("");32  const [saving, setSaving] = createSignal(false);33  const [showPassword, setShowPassword] = createSignal(false);34  const [showGenerator, setShowGenerator] = createSignal(false);35  const [copied, setCopied] = createSignal<string | null>(null);36  // Local draft for the additional URLs list, edited only while the URL field37  // is in inline-edit mode. Saved alongside `url` when the user confirms.38  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);39  const [editingTags, setEditingTags] = createSignal(false);40  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);41  const [tagInput, setTagInput] = createSignal("");42  const [showCardNumber, setShowCardNumber] = createSignal(false);43  const [showCardCvc, setShowCardCvc] = createSignal(false);44  const [totpTick, setTotpTick] = createSignal(0);45  const [confirmDelete, setConfirmDelete] = createSignal(false);46  const [totpHot, setTotpHot] = createSignal(false);47  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);48  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);49  // Auto-open advanced when entry already has passkey or canary configured50  const [showAdvanced, setShowAdvanced] = createSignal(false);51  let lastSavedNote: string | null = null;5253  function noteExec(cmd: string, val?: string) {54    document.execCommand(cmd, false, val);55    noteEditorEl()?.focus();56  }5758  async function saveNoteInline(html: string) {59    const entry = props.entry;60    if (!entry) return;61    const currentStored = entry.notes ?? "";62    if (html === currentStored) return;63    if (lastSavedNote !== null && html === lastSavedNote) return;64    lastSavedNote = html;65    setSaving(true);66    try {67      await sendMessage({68        type: "UPDATE_ENTRY",69        payload: { id: entry.id, patch: { notes: html } },70      });71      triggerRing();72      await props.onFieldSaved();73    } catch {74      lastSavedNote = null;75    } finally {76      setSaving(false);77    }78  }7980  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);81  onCleanup(() => window.clearInterval(interval));8283  const displayDomain = createMemo(() =>84    props.entry?.url ? getDisplayDomain(props.entry.url) : ""85  );8687  createEffect(() => {88    const domain = displayDomain();89    setFaviconSrc(domain ? faviconUrl(domain) : null);90  });9192  // Reset transient prompts (trash confirmation, inline edits, advanced panel)93  // when the selected entry changes so they don't carry over between entries.94  // Track the last id so a same-entry refresh (e.g. after saving a field)95  // doesn't blow away in-flight edit state — that race used to cancel96  // startEdit calls that ran right after onFieldSaved.97  let lastEntryId: string | null = null;98  createEffect(() => {99    const id = props.entry?.id ?? null;100    if (id === lastEntryId) return;101    lastEntryId = id;102    setConfirmDelete(false);103    setEditingField(null);104    setEditingTags(false);105    setShowAdvanced(false);106  });107108  const totpState = createMemo(() => {109    void totpTick();110    if (!props.entry?.totp) return null;111    return generateTOTP(props.entry.totp);112  });113114  let prevTotpCode: string | undefined;115  createEffect(() => {116    const state = totpState();117    if (!state) { prevTotpCode = undefined; return; }118    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {119      setTotpHot(true);120      window.setTimeout(() => setTotpHot(false), 3000);121    }122    prevTotpCode = state.code;123  });124125  const totpProgress = createMemo(() => {126    const state = totpState();127    if (!state) return 0;128    return ((state.remaining - 1) / 29) * 100;129  });130131  createEffect(() => {132    const shell = document.getElementById("hd-shell");133    if (!shell) return;134135    const state = totpState();136    if (!state) {137      delete shell.dataset.totpActive;138      shell.style.removeProperty("--peach-totp-progress");139      return;140    }141142    shell.dataset.totpActive = "true";143    shell.style.setProperty(144      "--peach-totp-progress",145      `${Math.max(0, totpProgress())}%`146    );147148    onCleanup(() => {149      delete shell.dataset.totpActive;150      shell.style.removeProperty("--peach-totp-progress");151    });152  });153154  const titleSizeClass = createMemo(() => {155    const length = props.entry?.title.length ?? 0;156    if (length >= 28) return "peach-entry-name--xxl";157    if (length >= 20) return "peach-entry-name--xl";158    if (length >= 14) return "peach-entry-name--lg";159    return "";160  });161  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);162163  // ── Inline editing ────────────────────────────────────────────────164165  function startEdit(field: EditableField, value: string) {166    setEditingField(field);167    setDraft(value);168    setShowGenerator(false);169    setConfirmDelete(false);170    if (field === "url") {171      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);172    }173  }174175  // Compare URLs by string after trim — vault.ts has its own canonicalisation176  // when saving, so the dedupe here is a UX-level "don't show the button if177  // we'd just add a duplicate" rather than a security check.178  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {179    const c = candidate.trim();180    if (!c) return true;181    if (primary.trim() === c) return true;182    return others.some((u) => u.trim() === c);183  }184185  function addCurrentSite(url: string) {186    if (!url) return;187    if (!draft().trim()) {188      setDraft(url);189      return;190    }191    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;192    setAdditionalUrlsDraft((arr) => [...arr, url]);193  }194195  async function saveField() {196    const field = editingField();197    const entry = props.entry;198    if (!field || !entry) return;199    // Don't trim passwords — spaces may be intentional200    let value = field === "password" ? draft() : draft().trim();201    if (field === "totp" && value.startsWith("otpauth://")) {202      const parsed = parseOtpauthURI(value);203      value = parsed?.secret ?? value;204    }205    if (field === "password" && entry.type !== "note" && !value) {206      return;207    }208    setSaving(true);209    try {210      const patch: Partial<VaultEntry> = { [field]: value };211      if (field === "url") {212        // Sanitization layer in vault.ts trims, dedupes, and drops empties.213        patch.additionalUrls = additionalUrlsDraft();214      }215      await sendMessage({216        type: "UPDATE_ENTRY",217        payload: { id: entry.id, patch },218      });219      triggerRing();220      setEditingField(null);221      setShowGenerator(false);222      await props.onFieldSaved();223    } catch {224      // silent — leave editing open225    } finally {226      setSaving(false);227    }228  }229230  function startEditTags() {231    setTagsDraft([...(props.entry?.tags ?? [])]);232    setTagInput("");233    setEditingTags(true);234  }235236  function addTag(value: string) {237    const n = value.toLowerCase().trim();238    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;239    setTagsDraft([...tagsDraft(), n]);240    setTagInput("");241  }242243  async function saveTags() {244    if (tagInput()) addTag(tagInput());245    const entry = props.entry;246    if (!entry) return;247    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;248    setEditingTags(false);249    setSaving(true);250    try {251      await sendMessage({252        type: "UPDATE_ENTRY",253        payload: { id: entry.id, patch: { tags: next } },254      });255      triggerRing();256      await props.onFieldSaved();257    } catch {258      // silent259    } finally {260      setSaving(false);261    }262  }263264  function cancelEdit() {265    setEditingField(null);266    setDraft("");267    setShowGenerator(false);268  }269270  // Switch from one identity slot to the other while editing.271  // If we're in the username slot with an email-shaped draft and no separate272  // email yet, migrate that value into entry.email so the now-empty username273  // slot can hold a plain username — this is the legacy-data fix-up path.274  async function addOtherIdentity(target: "username" | "email") {275    const entry = props.entry;276    if (!entry) return;277    const current = editingField();278    const value = draft().trim();279    if (current === "username" && target === "email") {280      // Commit current username draft (if changed), then open email editor.281      if (value !== (entry.username ?? "")) {282        setSaving(true);283        try {284          await sendMessage({285            type: "UPDATE_ENTRY",286            payload: { id: entry.id, patch: { username: value } },287          });288          await props.onFieldSaved();289        } finally {290          setSaving(false);291        }292      }293      startEdit("email", "");294      return;295    }296    if (current === "username" && target === "username") {297      // Already editing username — only meaningful when the saved username298      // is email-shaped. Migrate it to entry.email and reset the slot.299      if (value.includes("@") && !entry.email) {300        setSaving(true);301        try {302          await sendMessage({303            type: "UPDATE_ENTRY",304            payload: { id: entry.id, patch: { email: value, username: "" } },305          });306          await props.onFieldSaved();307        } finally {308          setSaving(false);309        }310      }311      startEdit("username", "");312      return;313    }314    if (current === "email" && target === "username") {315      // Commit current email draft (if changed), then open username editor.316      if (value !== (entry.email ?? "")) {317        setSaving(true);318        try {319          await sendMessage({320            type: "UPDATE_ENTRY",321            payload: { id: entry.id, patch: { email: value } },322          });323          await props.onFieldSaved();324        } finally {325          setSaving(false);326        }327      }328      startEdit("username", "");329      return;330    }331  }332333  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {334    switch (field) {335      case "title":336        return entry.title;337      case "url":338        return entry.url ?? "";339      case "username":340        return entry.username ?? "";341      case "email":342        return entry.email ?? "";343      case "password":344        return entry.password ?? "";345      case "notes":346        return entry.notes ?? "";347      case "totp":348        return entry.totp ?? "";349    }350  }351352  function handleEditorFocusOut(353    field: Exclude<EditableField, null>,354    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }355  ) {356    const nextTarget = event.relatedTarget;357    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;358359    const entry = props.entry;360    if (!entry) return;361362    const currentValue = fieldValue(entry, field);363    const nextValue = field === "password" ? draft() : draft().trim();364    if (nextValue === currentValue) {365      cancelEdit();366    }367  }368369  async function savePassword(password: string) {370    const entry = props.entry;371    if (!entry) return;372    setSaving(true);373    try {374      await sendMessage({375        type: "UPDATE_ENTRY",376        payload: { id: entry.id, patch: { password } },377      });378      triggerRing();379      setShowGenerator(false);380      await props.onFieldSaved();381    } catch {382      // silent383    } finally {384      setSaving(false);385    }386  }387388  // ── Copy ─────────────────────────────────────────────────────────389390  async function copyField(text: string, label: string, button?: HTMLButtonElement) {391    const ok = await popupCopy(text, label);392    if (ok) {393      const entry = props.entry;394      if (entry?.canary?.webhookUrl) {395        const trigger =396          label === "password"397            ? "password_copied"398            : label === "username"399              ? "username_copied"400              : undefined;401        if (trigger) {402          void sendMessage({403            type: "FIRE_CANARY_PING",404            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },405          });406        }407      }408      triggerRing();409      triggerPipPulse();410      setCopied(null);411      requestAnimationFrame(() => setCopied(label));412      if (button) {413        button.classList.remove("is-copied", "is-retreat");414        void button.offsetWidth;415        const drain = document.createElement("span");416        drain.className = "peach-copy-drain";417        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";418        drain.appendChild(bar);419        button.appendChild(drain);420        window.setTimeout(() => {421          button.classList.remove("is-copied");422          button.classList.add("is-retreat");423          drain.remove();424          window.setTimeout(() => button.classList.remove("is-retreat"), 160);425        }, 1800);426      }427      window.setTimeout(() => setCopied(null), 1800);428    }429  }430431  // ── Keyboard shortcuts ────────────────────────────────────────────432433  createEffect(() => {434    if (!props.entry) return;435    const handler = (e: KeyboardEvent) => {436      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;437      if (inInput) return;438      switch (e.key) {439        case "c": e.preventDefault();440          if (props.entry?.password) void copyField(props.entry.password, "password"); break;441        case "u": e.preventDefault();442          if (props.entry?.username) void copyField(props.entry.username, "username"); break;443        case "Escape": e.preventDefault(); props.onEscape?.(); break;444      }445    };446    window.addEventListener("keydown", handler);447    onCleanup(() => window.removeEventListener("keydown", handler));448  });449450  // ── Input ref focus helper ────────────────────────────────────────451452  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {453    requestAnimationFrame(() => { el.focus(); el.select?.(); });454  }455456  function onEditKeyDown(e: KeyboardEvent) {457    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {458      e.preventDefault();459      void saveField();460    }461    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }462  }463464  return (465    <>466      <div467        class="peach-ghost"468        aria-hidden="true"469        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;"470      >471        {props.entry?.title || "empty"}472      </div>473474      <div class="peach-stage" style="position:relative;">475        <Show when={props.entry} fallback={<EmptyVaultState />}>476          {(entry) => (477            <>478              {/* ── Breach strip ─────────────────────────────────── */}479              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>480                <div class="peach-breach-badge">481                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">482                    <path d="M6 1L11 10H1L6 1z" />483                    <line x1="6" y1="5" x2="6" y2="7.5" />484                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />485                  </svg>486                  <span style="flex:1">found in a known data breach</span>487                  <button488                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"489                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}490                  >491                    Change password492                  </button>493                </div>494              </Show>495496              {/* ── Title + trash ─────────────────────────────────── */}497              <div class="peach-entry-header">498                <div class="peach-entry-header-name">499                  <Show500                    when={editingField() === "title"}501                    fallback={502                      <button503                        class="peach-entry-name-button"504                        title="Click to edit title"505                        onClick={() => startEdit("title", entry().title)}506                      >507                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>508                          <span class="peach-entry-name-text">509                            <Show510                              when={useAnimatedTitle()}511                              fallback={entry().title}512                            >513                              <For each={entry().title.split("")}>514                                {(ch, i) => (515                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>516                                    {ch === " " ? "\u00A0" : ch}517                                  </span>518                                )}519                              </For>520                            </Show>521                          </span>522                        </h1>523                      </button>524                    }525                  >526                    <div527                      class="peach-inline-title-wrap"528                      onFocusOut={(event) => handleEditorFocusOut("title", event)}529                    >530                      <input531                        class="peach-inline-title-input"532                        value={draft()}533                        onInput={(e) => setDraft(e.currentTarget.value)}534                        onKeyDown={onEditKeyDown}535                        ref={autoFocus}536                        disabled={saving()}537                      />538                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />539                    </div>540                  </Show>541                </div>542543                {/* Subtle trash icon — hidden while editing title */}544                <Show when={editingField() !== "title"}>545                  <Show546                    when={confirmDelete()}547                    fallback={548                      <button549                        class="peach-entry-trash-btn"550                        onClick={() => setConfirmDelete(true)}551                        aria-label="Move to trash"552                        title="Delete entry"553                      >554                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">555                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />556                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />557                        </svg>558                      </button>559                    }560                  >561                    <div class="peach-entry-trash-confirm">562                      <span class="peach-entry-trash-confirm-label">trash?</span>563                      <button564                        class="peach-btn peach-btn--sm peach-btn--danger"565                        style="font-size:10px;padding:2px 8px;min-height:22px;"566                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}567                      >568                        yes569                      </button>570                      <button571                        class="peach-btn peach-btn--ghost peach-btn--sm"572                        style="font-size:10px;padding:2px 8px;min-height:22px;"573                        onClick={() => setConfirmDelete(false)}574                      >575                        no576                      </button>577                    </div>578                  </Show>579                </Show>580              </div>581582              {/* ── Domain / URL ─────────────────────────────────── */}583              <Show when={entry().type !== "note" && entry().type !== "card"}>584                <div class="peach-domain" style="position:relative;z-index:1;">585                  <Show586                    when={editingField() === "url"}587                    fallback={588                      <div589                        class="peach-domain-row peach-inline-clickable"590                        title="Click to edit URL"591                        onClick={() => startEdit("url", entry().url ?? "")}592                      >593                        <Show when={faviconSrc()}>594                          <img595                            src={faviconSrc()!}596                            width="16"597                            height="16"598                            alt=""599                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"600                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}601                          />602                        </Show>603                        <span class="peach-domain-value">604                          {displayDomain() || (entry().url ? entry().url : "add a website url")}605                        </span>606                        <span class="peach-inline-edit-hint">edit</span>607                      </div>608                    }609                  >610                    <div611                      class="peach-inline-field-wrap peach-inline-field-wrap--url"612                      onFocusOut={(event) => handleEditorFocusOut("url", event)}613                    >614                      <div class="peach-inline-field-row">615                        <input616                          class="peach-inline-input"617                          type="url"618                          placeholder="https://"619                          value={draft()}620                          onInput={(e) => setDraft(e.currentTarget.value)}621                          onKeyDown={onEditKeyDown}622                          ref={autoFocus}623                          disabled={saving()}624                        />625                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />626                      </div>627                      <div class="peach-additional-urls">628                        <Show when={additionalUrlsDraft().length > 0}>629                          <span class="peach-label peach-additional-urls-label">also matches</span>630                        </Show>631                        <For each={additionalUrlsDraft()}>632                          {(value, i) => (633                            <div class="peach-additional-url-row">634                              <input635                                class="peach-inline-input"636                                type="url"637                                placeholder="https://"638                                value={value}639                                onInput={(e) => setAdditionalUrlsDraft((arr) => {640                                  const next = [...arr];641                                  next[i()] = e.currentTarget.value;642                                  return next;643                                })}644                                disabled={saving()}645                              />646                              <button647                                class="peach-additional-url-remove"648                                type="button"649                                aria-label="Remove URL"650                                onMouseDown={(e) => e.preventDefault()}651                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}652                                disabled={saving()}653                              >×</button>654                            </div>655                          )}656                        </For>657                        <button658                          class="peach-add-field-link peach-additional-urls-add"659                          type="button"660                          onMouseDown={(e) => e.preventDefault()}661                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}662                          disabled={saving()}663                        >664                          + add another url665                        </button>666                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>667                          <button668                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"669                            type="button"670                            title={props.currentTabUrl}671                            onMouseDown={(e) => e.preventDefault()}672                            onClick={() => addCurrentSite(props.currentTabUrl!)}673                            disabled={saving()}674                          >675                            + add current site676                          </button>677                        </Show>678                      </div>679                    </div>680                  </Show>681                </div>682              </Show>683684              {/* ── Tags — read-only chips (editing state lives at bottom) */}685              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>686                <div687                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"688                  onClick={startEditTags}689                  title="Click to edit tags"690                >691                  <For each={entry().tags!}>692                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}693                  </For>694                </div>695              </Show>696697              {/* ── Note toolbar + inline editor ─────────────────── */}698              <Show when={entry().type === "note"}>699                <div class="peach-entry-note-toolbar-row">700                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />701                </div>702                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">703                  <NoteEditorInline704                    entryKey={entry().id}705                    initialContent={entry().notes ?? ""}706                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}707                    onBlur={saveNoteInline}708                    autoFocus={false}709                  />710                </section>711              </Show>712713              {/* ── Card detail view ─────────────────────────────── */}714              <Show when={entry().type === "card"}>715                <section class="peach-fields" style="position:relative;z-index:1;">716                  {/* Card subtitle: •••• last4 */}717                  <Show when={entry().last4}>718                    <div class="peach-field-card" style="padding-bottom:4px;">719                      <span class="peach-label">720                        <Show when={entry().network && entry().network !== "unknown"}>721                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>722                        </Show>723                        {entry().cardNickname || `•••• ${entry().last4}`}724                      </span>725                    </div>726                  </Show>727728                  {/* Cardholder name */}729                  <div class="peach-field-card">730                    <span class="peach-label">cardholder name</span>731                    <div class="peach-field-value-row">732                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>733                      <Show when={entry().cardholderName}>734                        <button735                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}736                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}737                          aria-label="Copy cardholder name"738                        >739                          <CopyGlyph /><span class="peach-copy-sheen" />740                        </button>741                      </Show>742                    </div>743                  </div>744745                  {/* Card number */}746                  <div class="peach-field-card">747                    <span class="peach-label">card number</span>748                    <div class="peach-field-value-row">749                      <span class={`peach-field-value peach-card-number`}>750                        <Show when={showCardNumber()} fallback={751                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>752                        }>753                          <span>{entry().cardNumber ?? ""}</span>754                        </Show>755                      </span>756                      <div class="peach-field-actions">757                        <button758                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"759                          onClick={() => setShowCardNumber(!showCardNumber())}760                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}761                        >762                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>763                            <EyeClosedGlyph />764                          </Show>765                        </button>766                        <Show when={entry().cardNumber}>767                          <button768                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}769                            onClick={(e) => {770                              const cardNum = entry().cardNumber!;771                              if (entry().canary?.webhookUrl) {772                                void sendMessage({773                                  type: "FIRE_CANARY_PING",774                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },775                                });776                              }777                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);778                            }}779                            aria-label="Copy card number"780                          >781                            <CopyGlyph /><span class="peach-copy-sheen" />782                          </button>783                        </Show>784                      </div>785                    </div>786                  </div>787788                  {/* Expiry */}789                  <Show when={entry().expMonth || entry().expYear}>790                    <div class="peach-field-card">791                      <span class="peach-label">expiry</span>792                      <div class="peach-field-value-row">793                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>794                        <button795                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}796                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}797                          aria-label="Copy expiry"798                        >799                          <CopyGlyph /><span class="peach-copy-sheen" />800                        </button>801                      </div>802                    </div>803                  </Show>804805                  {/* CVC */}806                  <Show when={entry().cvc}>807                    <div class="peach-field-card">808                      <span class="peach-label">CVC</span>809                      <div class="peach-field-value-row">810                        <span class="peach-field-value">811                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>812                            <span>{entry().cvc}</span>813                          </Show>814                        </span>815                        <div class="peach-field-actions">816                          <button817                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"818                            onClick={() => setShowCardCvc(!showCardCvc())}819                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}820                          >821                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>822                              <EyeClosedGlyph />823                            </Show>824                          </button>825                          <button826                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}827                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}828                            aria-label="Copy CVC"829                          >830                            <CopyGlyph /><span class="peach-copy-sheen" />831                          </button>832                        </div>833                      </div>834                    </div>835                  </Show>836                </section>837              </Show>838839              {/* ── Login fields ─────────────────────────────────── */}840              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>841                <section class="peach-fields" style="position:relative;z-index:1;">842843                  {/* Username — primary identity slot. Auto-labels as "email"844                      when the stored value is email-shaped and no separate845                      email field exists (legacy data path). */}846                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>847                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>848                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>849                      <Show850                        when={editingField() === "username"}851                        fallback={852                          <div class="peach-field-value-row">853                            <span854                              class="peach-field-value peach-inline-clickable"855                              title="Click to edit"856                              onClick={() => startEdit("username", entry().username ?? "")}857                            >858                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}859                            </span>860                            <Show when={entry().username}>861                              <button862                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}863                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}864                                aria-label="Copy username"865                              >866                                <CopyGlyph /><span class="peach-copy-sheen" />867                              </button>868                            </Show>869                          </div>870                        }871                      >872                        <div873                          class="peach-inline-field-wrap"874                          onFocusOut={(event) => handleEditorFocusOut("username", event)}875                        >876                          <input877                            class="peach-inline-input"878                            value={draft()}879                            onInput={(e) => setDraft(e.currentTarget.value)}880                            onKeyDown={onEditKeyDown}881                            ref={autoFocus}882                            disabled={saving()}883                            autocomplete="off"884                          />885                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />886                          {/* +add the OTHER identity. If the slot is acting as887                              email (value has @, no separate email), offer888                              "+ add username" — clicking migrates the email889                              into entry.email. Otherwise offer "+ add email". */}890                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>891                            <button892                              class="peach-add-field-btn"893                              type="button"894                              onMouseDown={(e) => e.preventDefault()}895                              onClick={() => {896                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);897                                const hasSeparateEmail = !!entry().email;898                                if (isEmailShape && !hasSeparateEmail) {899                                  void addOtherIdentity("username");900                                } else {901                                  void addOtherIdentity("email");902                                }903                              }}904                            >905                              {(() => {906                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);907                                const hasSeparateEmail = !!entry().email;908                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";909                              })()}910                            </button>911                          </Show>912                        </div>913                      </Show>914                    </div>915                  </Show>916917                  {/* Email — secondary identity slot. Visible whenever a918                      separate email field is set, or while it is being edited. */}919                  <Show when={entry().email || editingField() === "email"}>920                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>921                      <span class="peach-label">email</span>922                      <Show923                        when={editingField() === "email"}924                        fallback={925                          <div class="peach-field-value-row">926                            <span927                              class="peach-field-value peach-inline-clickable"928                              title="Click to edit"929                              onClick={() => startEdit("email", entry().email ?? "")}930                            >931                              {entry().email}932                            </span>933                            <Show when={entry().email}>934                              <button935                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}936                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}937                                aria-label="Copy email"938                              >939                                <CopyGlyph /><span class="peach-copy-sheen" />940                              </button>941                            </Show>942                          </div>943                        }944                      >945                        <div946                          class="peach-inline-field-wrap"947                          onFocusOut={(event) => handleEditorFocusOut("email", event)}948                        >949                          <input950                            class="peach-inline-input"951                            value={draft()}952                            onInput={(e) => setDraft(e.currentTarget.value)}953                            onKeyDown={onEditKeyDown}954                            ref={autoFocus}955                            disabled={saving()}956                            autocomplete="email"957                            type="email"958                          />959                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />960                          <Show when={!entry().username || entry().username?.includes("@")}>961                            <button962                              class="peach-add-field-btn"963                              type="button"964                              onMouseDown={(e) => e.preventDefault()}965                              onClick={() => void addOtherIdentity("username")}966                            >967                              + add username968                            </button>969                          </Show>970                        </div>971                      </Show>972                    </div>973                  </Show>974975                  {/* Password */}976                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>977                    <span class="peach-label">password</span>978                    <Show979                      when={editingField() === "password"}980                      fallback={981                        <div class="peach-field-value-row">982                          <span983                            class="peach-password-value"984                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}985                            style="cursor:pointer;"986                            title="Click to edit password"987                            onClick={() => startEdit("password", entry().password ?? "")}988                          >989                            <Show990                              when={showPassword()}991                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}992                            >993                              <span class="peach-password-text">{entry().password ?? ""}</span>994                            </Show>995                          </span>996                          <div class="peach-field-actions">997                            <button998                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"999                              onClick={() => setShowPassword(!showPassword())}1000                              aria-label="Reveal password"1001                            >1002                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1003                                <EyeClosedGlyph />1004                              </Show>1005                            </button>1006                            <button1007                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1008                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1009                              aria-label="Copy password"1010                            >1011                              <CopyGlyph /><span class="peach-copy-sheen" />1012                            </button>1013                          </div>1014                        </div>1015                      }1016                    >1017                      {/* ── Edit mode: type / paste / generate ── */}1018                      <div1019                        class="peach-inline-field-wrap"1020                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1021                      >1022                        <div class="peach-password-edit-row">1023                          <input1024                            class="peach-inline-input"1025                            type={showPassword() ? "text" : "password"}1026                            placeholder="paste or type a password"1027                            value={draft()}1028                            onInput={(e) => setDraft(e.currentTarget.value)}1029                            onKeyDown={(e) => {1030                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1031                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1032                            }}1033                            ref={autoFocus}1034                            disabled={saving()}1035                            autocomplete="new-password"1036                          />1037                          <button1038                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1039                            type="button"1040                            onClick={() => setShowPassword(!showPassword())}1041                            aria-label={showPassword() ? "Hide" : "Show"}1042                          >1043                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1044                              <EyeClosedGlyph />1045                            </Show>1046                          </button>1047                        </div>1048                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1049                        <button1050                          class="peach-generate-toggle-link"1051                          type="button"1052                          onClick={() => setShowGenerator(!showGenerator())}1053                        >1054                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1055                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1056                            <path d="M9.5 2.5H11V1" />1057                          </svg>1058                          {showGenerator() ? "hide generator" : "generate"}1059                        </button>1060                        <Show when={showGenerator()}>1061                          <GeneratorDrawer1062                            open={true}1063                            compact={true}1064                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1065                          />1066                        </Show>1067                      </div>1068                    </Show>1069                  </div>10701071                </section>1072              </Show>10731074              {/* ── Notes (on login entries) ──────────────────────── */}1075              <Show when={entry().type !== "note" && entry().type !== "card"}>1076                <Show1077                  when={entry().notes || editingField() === "notes"}1078                  fallback={1079                    <button1080                      class="peach-add-field-link peach-entry-add-link"1081                      onClick={() => startEdit("notes", "")}1082                    >1083                      + notes1084                    </button>1085                  }1086                >1087                  <div style="position:relative;z-index:1;margin-top:6px;">1088                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1089                    <Show1090                      when={editingField() === "notes"}1091                      fallback={1092                        <div1093                          class="peach-note-content peach-inline-clickable"1094                          style="cursor:text;white-space:pre-wrap;"1095                          onClick={() => startEdit("notes", entry().notes ?? "")}1096                        >1097                          {entry().notes}1098                        </div>1099                      }1100                    >1101                      <div1102                        class="peach-inline-field-wrap"1103                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1104                      >1105                        <textarea1106                          class="peach-inline-textarea"1107                          value={draft()}1108                          onInput={(e) => setDraft(e.currentTarget.value)}1109                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1110                          ref={(el) => requestAnimationFrame(() => el?.focus())}1111                          rows={4}1112                          disabled={saving()}1113                        />1114                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1115                      </div>1116                    </Show>1117                  </div>1118                </Show>1119              </Show>11201121              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1122              <Show1123                when={editingTags()}1124                fallback={1125                  <Show when={!entry().tags || entry().tags!.length === 0}>1126                    <button1127                      class="peach-add-field-link peach-entry-add-link"1128                      onClick={startEditTags}1129                    >1130                      + tags1131                    </button>1132                  </Show>1133                }1134              >1135                <div1136                  class="peach-inline-tag-editor"1137                  onFocusOut={(e) => {1138                    const next = e.relatedTarget;1139                    if (next instanceof Node && e.currentTarget.contains(next)) return;1140                    void saveTags();1141                  }}1142                >1143                  <span class="peach-label">tags</span>1144                  <div class="peach-tag-input-wrap">1145                    <For each={tagsDraft()}>1146                      {(tag) => (1147                        <span class="peach-tag-chip">1148                          {tag}1149                          <button1150                            class="peach-tag-chip-remove"1151                            onMouseDown={(e) => e.preventDefault()}1152                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1153                            aria-label={`Remove ${tag}`}1154                          >×</button>1155                        </span>1156                      )}1157                    </For>1158                    <input1159                      class="peach-tag-input"1160                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1161                      value={tagInput()}1162                      onInput={(e) => setTagInput(e.currentTarget.value)}1163                      onKeyDown={(e) => {1164                        if (e.key === "Enter" || e.key === ",") {1165                          e.preventDefault();1166                          addTag(tagInput());1167                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1168                          setTagsDraft(tagsDraft().slice(0, -1));1169                        } else if (e.key === "Escape") {1170                          e.preventDefault();1171                          setEditingTags(false);1172                          setTagInput("");1173                        }1174                      }}1175                      ref={(el) => requestAnimationFrame(() => el?.focus())}1176                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1177                    />1178                  </div>1179                </div>1180              </Show>11811182              {/* ── TOTP ─────────────────────────────────────────── */}1183              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>1184                <Show1185                  when={totpState() || editingField() === "totp"}1186                  fallback={1187                    <button1188                      class="peach-add-field-link peach-entry-add-link"1189                      onClick={() => startEdit("totp", "")}1190                    >1191                      + 2FA1192                    </button>1193                  }1194                >1195                  <section class="peach-totp-band">1196                    <Show1197                      when={editingField() === "totp"}1198                      fallback={1199                        <Show when={totpState()}>1200                          {(state) => (1201                            <>1202                              <div1203                                class="peach-totp-main"1204                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1205                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1206                                title="Click to copy · double-click to edit"1207                              >1208                                <span class="peach-label">2-factor</span>1209                                <div class="peach-totp-code-row">1210                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1211                                    <span class="peach-totp-code-text">1212                                      {state().code.slice(0, 3)} {state().code.slice(3)}1213                                    </span>1214                                  </p>1215                                </div>1216                              </div>1217                              <div class="peach-totp-meta">1218                                <span class="peach-label">expires</span>1219                                <div class="peach-totp-countdown">{state().remaining}s</div>1220                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1221                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1222                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1223                                    stroke-dasharray="106.8"1224                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1225                                  />1226                                </svg>1227                              </div>1228                            </>1229                          )}1230                        </Show>1231                      }1232                    >1233                      <div1234                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1235                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1236                      >1237                        <span class="peach-label">2-factor</span>1238                        <input1239                          class="peach-inline-input"1240                          value={draft()}1241                          onInput={(e) => setDraft(e.currentTarget.value)}1242                          onKeyDown={onEditKeyDown}1243                          ref={autoFocus}1244                          disabled={saving()}1245                          placeholder="otpauth://... or base32 secret"1246                          autocomplete="off"1247                        />1248                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1249                      </div>1250                    </Show>1251                  </section>1252                </Show>1253              </Show>12541255              {/* ── Advanced (canary + passkey) ───────────────────── */}1256              <Show when={entry().type === "login"}>1257                <button1258                  class="peach-add-field-link peach-entry-add-link"1259                  onClick={() => setShowAdvanced((v) => !v)}1260                  aria-expanded={showAdvanced()}1261                >1262                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1263                </button>1264                <Show when={showAdvanced()}>1265                  <Show when={!isPasskeyOnly(entry())}>1266                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} />1267                  </Show>12681269                  <Show when={entry().passkey}>1270                    {(passkey) => {1271                      const currentBrowser = detectBrowser();1272                      const crossBrowser =1273                        passkey().browser &&1274                        currentBrowser !== "unknown" &&1275                        passkey().browser !== currentBrowser;1276                      const algorithmLabel =1277                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1278                      const formatDate = (ts?: number) =>1279                        ts ? new Date(ts).toLocaleDateString() : "Never";1280                      return (1281                        <section1282                          class="peach-fields peach-passkey-section"1283                          style="position:relative;z-index:1;margin-top:6px;"1284                        >1285                          <div class="peach-field-card">1286                            <span class="peach-label">passkey · {passkey().rpId}</span>1287                            <div class="peach-field-value-row">1288                              <span class="peach-field-value">{passkey().userName}</span>1289                              <span class="peach-passkey-badge">passkey</span>1290                            </div>1291                          </div>12921293                          <div class="peach-field-card">1294                            <span class="peach-label">algorithm</span>1295                            <div class="peach-field-value-row">1296                              <span class="peach-field-value">{algorithmLabel}</span>1297                            </div>1298                          </div>12991300                          <div class="peach-field-card">1301                            <span class="peach-label">activity</span>1302                            <div class="peach-field-value-row peach-passkey-activity">1303                              <span class="peach-field-value">1304                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1305                              </span>1306                              <span class="peach-passkey-meta">1307                                created {formatDate(passkey().createdAt)}1308                              </span>1309                              <span class="peach-passkey-meta">1310                                last used {formatDate(passkey().lastUsedAt)}1311                              </span>1312                            </div>1313                          </div>13141315                          <Show when={crossBrowser}>1316                            <div class="peach-info-notice peach-info-notice--warn">1317                              {`This passkey was created on ${1318                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1319                              }. To use it on ${1320                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1321                              }, re-register it on this device.`}1322                            </div>1323                          </Show>13241325                          <div class="peach-info-notice">1326                            {passkey().browser === "firefox"1327                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1328                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1329                          </div>13301331                          <button1332                            class="peach-add-field-link peach-entry-add-link"1333                            style="color:rgba(220,38,38,0.7);"1334                            onClick={async () => {1335                              const id = entry().id;1336                              await sendMessage({1337                                type: "UPDATE_ENTRY",1338                                payload: { id, patch: { passkey: undefined } },1339                              });1340                              await props.onFieldSaved();1341                            }}1342                          >1343                            − remove passkey1344                          </button>1345                        </section>1346                      );1347                    }}1348                  </Show>1349                </Show>1350              </Show>13511352              {/* ── Bottom spacer ─────────────────────────────────── */}1353              <div style="height:4px;" />1354            </>1355          )}1356        </Show>1357      </div>13581359    </>1360  );1361}13621363// ── Sub-components ────────────────────────────────────────────────────13641365function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1366  return (1367    <div class="peach-inline-actions">1368      <button1369        class="peach-inline-save-btn"1370        onClick={props.onSave}1371        disabled={props.saving}1372        aria-label="Save"1373        title="Save (Enter)"1374      >1375        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1376          <path d="M1 4.5L4 7.5L10 1.5" />1377        </svg>1378      </button>1379      <button1380        class="peach-inline-cancel-btn"1381        onClick={props.onCancel}1382        disabled={props.saving}1383        aria-label="Cancel"1384        title="Cancel (Esc)"1385      >1386        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1387          <path d="M1 1L8 8M8 1L1 8" />1388        </svg>1389      </button>1390    </div>1391  );1392}13931394function EmptyVaultState() {1395  return (1396    <>1397      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1398        <div class="peach-entry-name">1399          <span class="peach-entry-name-text">1400            <span class="peach-entry-name-char" style="--char-index:0">e</span>1401            <span class="peach-entry-name-char" style="--char-index:1">m</span>1402            <span class="peach-entry-name-char" style="--char-index:2">p</span>1403            <span class="peach-entry-name-char" style="--char-index:3">t</span>1404            <span class="peach-entry-name-char" style="--char-index:4">y</span>1405          </span>1406        </div>1407      </div>1408      <div class="peach-empty-copy">1409        <span class="peach-label">vault</span>1410        <p>Your vault is ready for its first entry.</p>1411      </div>1412    </>1413  );1414}14151416function CopyGlyph() {1417  return (1418    <span class="peach-copy-icon">1419      <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">1420        <rect x="4" y="4" width="8" height="9" rx="1.5" />1421        <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" />1422      </svg>1423    </span>1424  );1425}14261427function EyeClosedGlyph() {1428  return (1429    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1430      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1431      <path d="M3.5 5.5L2.5 7.5" />1432      <path d="M7 6.5V8.5" />1433      <path d="M10.5 5.5L11.5 7.5" />1434    </svg>1435  );1436}14371438function EyeOpenGlyph() {1439  return (1440    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1441      <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" />1442      <circle cx="7" cy="5" r="1.75" />1443    </svg>1444  );1445}14461447function ChevronGlyph(props: { open: boolean }) {1448  return (1449    <svg1450      width="8"1451      height="8"1452      viewBox="0 0 8 8"1453      fill="none"1454      stroke="currentColor"1455      stroke-width="1.3"1456      stroke-linecap="round"1457      stroke-linejoin="round"1458      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1459    >1460      <path d="M2 1L6 4L2 7" />1461    </svg>1462  );1463}14641465function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1466  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1467  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1468  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1469  const [testResult, setTestResult] = createSignal<string | null>(null);1470  const [testing, setTesting] = createSignal(false);14711472  async function save() {1473    const url = webhookUrl().trim();1474    const nextLabel = label().trim() || undefined;1475    const canary = enabled() && url1476      ? {1477          webhookUrl: url,1478          label: nextLabel,1479          lastPinged: props.entry.canary?.lastPinged,1480          pingCount: props.entry.canary?.pingCount,1481        }1482      : undefined;1483    await sendMessage({1484      type: "UPDATE_ENTRY",1485      payload: { id: props.entry.id, patch: { canary } },1486    });1487    await props.onSaved();1488  }14891490  async function testPing() {1491    const url = webhookUrl().trim();1492    if (!url) return;1493    setTesting(true);1494    setTestResult(null);1495    const res = await sendMessage({1496      type: "FIRE_CANARY_PING",1497      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1498    }) as { ok: boolean; error?: string };1499    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1500    setTesting(false);1501  }15021503  return (1504    <div class="peach-canary-section">1505      <div class="peach-field-value-row">1506        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1507        <button1508          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1509          onClick={() => { setEnabled(!enabled()); void save(); }}1510          aria-label="Toggle canary"1511        >1512          <span class="peach-toggle-knob" />1513        </button>1514      </div>1515      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1516        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1517      </p>1518      <Show when={enabled()}>1519        <div class="peach-inline-field-wrap" style="margin-top:8px;">1520          <input1521            class="peach-inline-input"1522            placeholder="https://hooks.zapier.com/..."1523            value={webhookUrl()}1524            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1525            onBlur={() => void save()}1526          />1527          <input1528            class="peach-inline-input"1529            placeholder="label (optional, e.g. AWS decoy)"1530            value={label()}1531            onInput={(e) => setLabel(e.currentTarget.value)}1532            onBlur={() => void save()}1533          />1534          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1535            <button1536              class="peach-btn peach-btn--ghost peach-btn--sm"1537              onClick={() => void testPing()}1538              disabled={testing() || !webhookUrl().trim()}1539            >1540              {testing() ? "sending..." : "test ping"}1541            </button>1542            <Show when={testResult()}>1543              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1544            </Show>1545          </div>1546          <Show when={props.entry.canary?.lastPinged}>1547            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1548              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1549              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1550            </p>1551          </Show>1552        </div>1553      </Show>1554    </div>1555  );1556}