Entry.tsx

75.0 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { ProFeature, VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } 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";12import { IdentitySuggestList } from "./Add";1314type EditableField = "title" | "url" | "username" | "email" | "password" | "notes" | "totp" | null;1516interface EntryProps {17  entry: VaultEntry | null;18  /**19   * Current active tab's full URL (https only). Empty string when unavailable20   * — Capacitor, an extension page, or a chrome:///about: URL. The URL21   * editor uses this to render an "add current site" shortcut.22   */23  currentTabUrl?: string;24  onDelete: () => void;25  /** Called after any inline save so App can reload entries */26  onFieldSaved: () => void | Promise<void>;27  onEscape?: () => void;28  entries?: VaultEntry[];29  /** Whether the user holds an active Peach Pro license. Drives Pro gates on30   *  canary entries (see CanarySection). */31  isPro?: boolean;32  /** Invoked when a Pro-gated control is tapped on free tier. App owns the33   *  upgrade navigation + highlight state. */34  onProGate?: (feature: ProFeature) => void;35}3637// Inline 16×16 favicon for the domain row — shows coloured initials until a38// real favicon is confirmed (same globe-detection logic as the vault list).39function DomainFavicon(props: { domain: string; title: string }) {40  const src = props.domain ? faviconUrl(props.domain) : null;41  const [isReal, setIsReal] = createSignal(false);42  const label = () => titleInitials(props.title || props.domain);43  const bg = () => titleBackground(props.title || props.domain);44  const chipStyle = () =>45    `display:inline-flex;align-items:center;justify-content:center;` +46    `width:16px;height:16px;border-radius:3px;background:${bg()};` +47    `font-family:"IBM Plex Mono",monospace;font-size:0.4375rem;font-weight:600;` +48    `letter-spacing:0.04em;color:rgba(240,232,216,0.92);flex-shrink:0;margin-right:5px;`;49  return (50    <Show51      when={isReal()}52      fallback={53        <>54          <span style={chipStyle()} aria-hidden="true">{label()}</span>55          {src && (56            <img57              src={src}58              style="display:none"59              onError={() => {}}60              onLoad={async (e) => {61                const img = e.currentTarget as HTMLImageElement;62                if (img.naturalWidth <= 16) return;63                const sig = faviconImgSig(img);64                if (sig !== null) {65                  const gSig = await globeFaviconSig();66                  if (gSig !== null && sig === gSig) return;67                }68                setIsReal(true);69              }}70            />71          )}72        </>73      }74    >75      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />76    </Show>77  );78}7980export function EntryView(props: EntryProps) {81  const [editingField, setEditingField] = createSignal<EditableField>(null);82  const [draft, setDraft] = createSignal("");83  const [saving, setSaving] = createSignal(false);84  const [showPassword, setShowPassword] = createSignal(false);85  const [showGenerator, setShowGenerator] = createSignal(false);86  const [copied, setCopied] = createSignal<string | null>(null);87  // Local draft for the additional URLs list, edited only while the URL field88  // is in inline-edit mode. Saved alongside `url` when the user confirms.89  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);90  const [editingTags, setEditingTags] = createSignal(false);91  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);92  const [tagInput, setTagInput] = createSignal("");93  const [showCardNumber, setShowCardNumber] = createSignal(false);94  const [showCardCvc, setShowCardCvc] = createSignal(false);95  const [totpTick, setTotpTick] = createSignal(0);96  const [confirmDelete, setConfirmDelete] = createSignal(false);97  const [totpHot, setTotpHot] = createSignal(false);98  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);99  // Auto-open advanced when entry already has passkey or canary configured100  const [showAdvanced, setShowAdvanced] = createSignal(false);101  const [identityHelperSuppressed, setIdentityHelperSuppressed] = createSignal(false);102  let usernameInlineInput: HTMLInputElement | undefined;103  let emailInlineInput: HTMLInputElement | undefined;104  let usernameIdentityEl: HTMLDivElement | undefined;105  let emailIdentityEl: HTMLDivElement | undefined;106  let lastSavedNote: string | null = null;107  let totpClickTimer: ReturnType<typeof setTimeout> | null = null;108  onCleanup(() => { if (totpClickTimer) clearTimeout(totpClickTimer); });109110  function noteExec(cmd: string, val?: string) {111    document.execCommand(cmd, false, val);112    noteEditorEl()?.focus();113  }114115  async function saveNoteInline(html: string) {116    const entry = props.entry;117    if (!entry) return;118    const currentStored = entry.notes ?? "";119    if (html === currentStored) return;120    if (lastSavedNote !== null && html === lastSavedNote) return;121    lastSavedNote = html;122    setSaving(true);123    try {124      await sendMessage({125        type: "UPDATE_ENTRY",126        payload: { id: entry.id, patch: { notes: html } },127      });128      triggerRing();129      await props.onFieldSaved();130    } catch {131      lastSavedNote = null;132    } finally {133      setSaving(false);134    }135  }136137  const allIdentities = createMemo(() => {138    const emailMap = new Map<string, number>();139    const usernameMap = new Map<string, number>();140    for (const e of props.entries ?? []) {141      const uname = e.username?.trim() ?? "";142      const em = e.email?.trim() ?? "";143      if (uname) {144        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);145        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);146      }147      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);148    }149    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];150    const seen = new Set<string>();151    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {152      if (seen.has(v)) continue;153      seen.add(v);154      const ec = emailMap.get(v) ?? 0;155      const uc = usernameMap.get(v) ?? 0;156      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });157    }158    return result.sort((a, b) => b.count - a.count);159  });160161  const identitySuggestions = createMemo(() => {162    const field = editingField();163    if (field !== "username" && field !== "email") return [];164    const input = draft().toLowerCase();165    return allIdentities()166      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))167      .slice(0, 8);168  });169170  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);171  onCleanup(() => window.clearInterval(interval));172173  const displayDomain = createMemo(() =>174    props.entry?.url ? getDisplayDomain(props.entry.url) : ""175  );176177  // Reset transient prompts (trash confirmation, inline edits, advanced panel)178  // when the selected entry changes so they don't carry over between entries.179  // Track the last id so a same-entry refresh (e.g. after saving a field)180  // doesn't blow away in-flight edit state — that race used to cancel181  // startEdit calls that ran right after onFieldSaved.182  let lastEntryId: string | null = null;183  createEffect(() => {184    const id = props.entry?.id ?? null;185    if (id === lastEntryId) return;186    lastEntryId = id;187    setConfirmDelete(false);188    setEditingField(null);189    setEditingTags(false);190    setShowAdvanced(false);191  });192193  const totpState = createMemo(() => {194    void totpTick();195    if (!props.entry?.totp) return null;196    return generateTOTP(props.entry.totp);197  });198199  let prevTotpCode: string | undefined;200  createEffect(() => {201    const state = totpState();202    if (!state) { prevTotpCode = undefined; return; }203    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {204      setTotpHot(true);205      window.setTimeout(() => setTotpHot(false), 3000);206    }207    prevTotpCode = state.code;208  });209210  const totpProgress = createMemo(() => {211    const state = totpState();212    if (!state) return 0;213    return ((state.remaining - 1) / 29) * 100;214  });215216  createEffect(() => {217    const shell = document.getElementById("hd-shell");218    if (!shell) return;219220    const state = totpState();221    if (!state) {222      delete shell.dataset.totpActive;223      shell.style.removeProperty("--peach-totp-progress");224      return;225    }226227    shell.dataset.totpActive = "true";228    shell.style.setProperty(229      "--peach-totp-progress",230      `${Math.max(0, totpProgress())}%`231    );232233    onCleanup(() => {234      delete shell.dataset.totpActive;235      shell.style.removeProperty("--peach-totp-progress");236    });237  });238239  const titleSizeClass = createMemo(() => {240    const length = props.entry?.title.length ?? 0;241    if (length >= 28) return "peach-entry-name--xxl";242    if (length >= 20) return "peach-entry-name--xl";243    if (length >= 14) return "peach-entry-name--lg";244    return "";245  });246  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);247248  // ── Inline editing ────────────────────────────────────────────────249250  function startEdit(field: EditableField, value: string) {251    setEditingField(field);252    setDraft(value);253    setIdentityHelperSuppressed(false);254    setShowGenerator(false);255    setConfirmDelete(false);256    if (field === "url") {257      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);258    }259  }260261  // Compare URLs by string after trim — vault.ts has its own canonicalisation262  // when saving, so the dedupe here is a UX-level "don't show the button if263  // we'd just add a duplicate" rather than a security check.264  function urlMatchesAny(candidate: string, primary: string, others: string[]): boolean {265    const c = candidate.trim();266    if (!c) return true;267    if (primary.trim() === c) return true;268    return others.some((u) => u.trim() === c);269  }270271  function addCurrentSite(url: string) {272    if (!url) return;273    if (!draft().trim()) {274      setDraft(url);275      return;276    }277    if (urlMatchesAny(url, draft(), additionalUrlsDraft())) return;278    setAdditionalUrlsDraft((arr) => [...arr, url]);279  }280281  async function saveField() {282    const field = editingField();283    const entry = props.entry;284    if (!field || !entry) return;285    // Don't trim passwords — spaces may be intentional286    let value = field === "password" ? draft() : draft().trim();287    if (field === "totp" && value.startsWith("otpauth://")) {288      const parsed = parseOtpauthURI(value);289      value = parsed?.secret ?? value;290    }291    if (field === "password" && entry.type !== "note" && !value) {292      return;293    }294    setSaving(true);295    try {296      const patch: Partial<VaultEntry> = { [field]: value };297      if (field === "url") {298        // Sanitization layer in vault.ts trims, dedupes, and drops empties.299        patch.additionalUrls = additionalUrlsDraft();300      }301      await sendMessage({302        type: "UPDATE_ENTRY",303        payload: { id: entry.id, patch },304      });305      triggerRing();306      setEditingField(null);307      setShowGenerator(false);308      await props.onFieldSaved();309    } catch {310      // silent — leave editing open311    } finally {312      setSaving(false);313    }314  }315316  function startEditTags() {317    setTagsDraft([...(props.entry?.tags ?? [])]);318    setTagInput("");319    setEditingTags(true);320  }321322  function addTag(value: string) {323    const n = value.toLowerCase().trim();324    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;325    setTagsDraft([...tagsDraft(), n]);326    setTagInput("");327  }328329  async function saveTags() {330    if (tagInput()) addTag(tagInput());331    const entry = props.entry;332    if (!entry) return;333    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;334    setEditingTags(false);335    setSaving(true);336    try {337      await sendMessage({338        type: "UPDATE_ENTRY",339        payload: { id: entry.id, patch: { tags: next } },340      });341      triggerRing();342      await props.onFieldSaved();343    } catch {344      // silent345    } finally {346      setSaving(false);347    }348  }349350  function cancelEdit() {351    setEditingField(null);352    setDraft("");353    setShowGenerator(false);354  }355356  // Switch from one identity slot to the other.357  //358  // Migration source is the SAVED entry value, never the live draft — using359  // the draft caused data loss when the user typed or cleared the input360  // before clicking +add (the migration would skip and a later Save would361  // overwrite the original email-shape value with whatever was in the input).362  //363  // Drafts only get committed when they are non-empty AND actually changed,364  // so an empty input never accidentally clears a saved field.365  async function addOtherIdentity(target: "username" | "email") {366    const entry = props.entry;367    if (!entry) return;368    const current = editingField();369    const draftValue = draft().trim();370    const savedUsername = entry.username ?? "";371    const savedEmail = entry.email ?? "";372373    if (current === "username" && target === "username") {374      // Legacy fix-up: saved username is email-shaped and no separate email375      // exists yet. Move it to entry.email and free the username slot.376      if (savedUsername.includes("@") && !entry.email) {377        setSaving(true);378        try {379          await sendMessage({380            type: "UPDATE_ENTRY",381            payload: { id: entry.id, patch: { email: savedUsername, username: "" } },382          });383          await props.onFieldSaved();384        } finally {385          setSaving(false);386        }387      }388      startEdit("username", "");389      return;390    }391392    if (current === "username" && target === "email") {393      if (draftValue && draftValue !== savedUsername) {394        setSaving(true);395        try {396          await sendMessage({397            type: "UPDATE_ENTRY",398            payload: { id: entry.id, patch: { username: draftValue } },399          });400          await props.onFieldSaved();401        } finally {402          setSaving(false);403        }404      }405      startEdit("email", "");406      return;407    }408409    if (current === "email" && target === "username") {410      if (draftValue && draftValue !== savedEmail) {411        setSaving(true);412        try {413          await sendMessage({414            type: "UPDATE_ENTRY",415            payload: { id: entry.id, patch: { email: draftValue } },416          });417          await props.onFieldSaved();418        } finally {419          setSaving(false);420        }421      }422      startEdit("username", "");423      return;424    }425  }426427  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {428    switch (field) {429      case "title":430        return entry.title;431      case "url":432        return entry.url ?? "";433      case "username":434        return entry.username ?? "";435      case "email":436        return entry.email ?? "";437      case "password":438        return entry.password ?? "";439      case "notes":440        return entry.notes ?? "";441      case "totp":442        return entry.totp ?? "";443    }444  }445446  function handleEditorFocusOut(447    field: Exclude<EditableField, null>,448    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }449  ) {450    const nextTarget = event.relatedTarget;451    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;452453    // addOtherIdentity / startEdit may have already switched editingField before454    // focusout fires (SolidJS removes the old editor node synchronously, which455    // triggers blur with relatedTarget=null). Don't cancel the new edit.456    if (editingField() !== field) return;457458    const entry = props.entry;459    if (!entry) return;460461    const currentValue = fieldValue(entry, field);462    const nextValue = field === "password" ? draft() : draft().trim();463    if (nextValue === currentValue) {464      cancelEdit();465    }466  }467468  // ── Copy ─────────────────────────────────────────────────────────469470  async function copyField(text: string, label: string, button?: HTMLButtonElement) {471    const ok = await popupCopy(text, label);472    if (ok) {473      const entry = props.entry;474      if (entry?.canary?.webhookUrl) {475        const trigger =476          label === "password"477            ? "password_copied"478            : label === "username"479              ? "username_copied"480              : undefined;481        if (trigger) {482          void sendMessage({483            type: "FIRE_CANARY_PING",484            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },485          });486        }487      }488      triggerRing();489      triggerPipPulse();490      setCopied(null);491      requestAnimationFrame(() => setCopied(label));492      if (button) {493        button.classList.remove("is-copied", "is-retreat");494        void button.offsetWidth;495        const drain = document.createElement("span");496        drain.className = "peach-copy-drain";497        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";498        drain.appendChild(bar);499        button.appendChild(drain);500        window.setTimeout(() => {501          button.classList.remove("is-copied");502          button.classList.add("is-retreat");503          drain.remove();504          window.setTimeout(() => button.classList.remove("is-retreat"), 160);505        }, 1800);506      }507      window.setTimeout(() => setCopied(null), 1800);508    }509  }510511  // ── Keyboard shortcuts ────────────────────────────────────────────512513  createEffect(() => {514    if (!props.entry) return;515    const handler = (e: KeyboardEvent) => {516      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;517      if (inInput) return;518      switch (e.key) {519        case "c": e.preventDefault();520          if (props.entry?.password) void copyField(props.entry.password, "password"); break;521        case "u": e.preventDefault();522          if (props.entry?.username) void copyField(props.entry.username, "username"); break;523        case "Escape": e.preventDefault(); props.onEscape?.(); break;524      }525    };526    window.addEventListener("keydown", handler);527    onCleanup(() => window.removeEventListener("keydown", handler));528  });529530  // ── Input ref focus helper ────────────────────────────────────────531532  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {533    requestAnimationFrame(() => { el.focus(); el.select?.(); });534  }535536  function onEditKeyDown(e: KeyboardEvent) {537    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {538      e.preventDefault();539      void saveField();540    }541    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }542  }543544  function identityKeyDown(e: KeyboardEvent, field: "username" | "email") {545    const listEl = field === "username" ? usernameIdentityEl : emailIdentityEl;546    if (e.key === "ArrowDown" && listEl) {547      e.preventDefault();548      listEl.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();549      return;550    }551    onEditKeyDown(e);552  }553554  return (555    <>556      <div557        class="peach-ghost"558        aria-hidden="true"559        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:var(--peach-ghost-opacity,0.03);pointer-events:none;line-height:1.25;overflow:hidden;white-space:nowrap;z-index:0;"560      >561        {props.entry?.title || "empty"}562      </div>563564      <div class="peach-stage" style="position:relative;">565        <Show when={props.entry} fallback={<EmptyVaultState />}>566          {(entry) => (567            <>568              {/* ── Breach strip ─────────────────────────────────── */}569              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>570                <div class="peach-breach-badge">571                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">572                    <path d="M6 1L11 10H1L6 1z" />573                    <line x1="6" y1="5" x2="6" y2="7.5" />574                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />575                  </svg>576                  <span style="flex:1">found in a known data breach</span>577                </div>578              </Show>579580              {/* ── Title + trash ─────────────────────────────────── */}581              <div class="peach-entry-header">582                <div class="peach-entry-header-name">583                  <Show584                    when={editingField() === "title"}585                    fallback={586                      <button587                        class="peach-entry-name-button"588                        title="Click to edit title"589                        onClick={() => startEdit("title", entry().title)}590                      >591                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>592                          <span class="peach-entry-name-text">593                            <Show594                              when={useAnimatedTitle()}595                              fallback={entry().title}596                            >597                              <For each={entry().title.split("")}>598                                {(ch, i) => (599                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>600                                    {ch === " " ? "\u00A0" : ch}601                                  </span>602                                )}603                              </For>604                            </Show>605                          </span>606                        </h1>607                      </button>608                    }609                  >610                    <div611                      class="peach-inline-title-wrap"612                      onFocusOut={(event) => handleEditorFocusOut("title", event)}613                    >614                      <input615                        class="peach-inline-title-input"616                        value={draft()}617                        onInput={(e) => setDraft(e.currentTarget.value)}618                        onKeyDown={onEditKeyDown}619                        ref={autoFocus}620                        disabled={saving()}621                      />622                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />623                    </div>624                  </Show>625                </div>626627                {/* Subtle trash icon — hidden while editing title */}628                <Show when={editingField() !== "title"}>629                  <Show630                    when={confirmDelete()}631                    fallback={632                      <button633                        class="peach-entry-trash-btn"634                        onClick={() => setConfirmDelete(true)}635                        aria-label="Move to trash"636                        title="Delete entry"637                      >638                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">639                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />640                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />641                        </svg>642                      </button>643                    }644                  >645                    <div class="peach-entry-trash-confirm">646                      <span class="peach-entry-trash-confirm-label">trash?</span>647                      <button648                        class="peach-btn peach-btn--sm peach-btn--danger"649                        style="font-size:10px;padding:2px 8px;min-height:22px;"650                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}651                      >652                        yes653                      </button>654                      <button655                        class="peach-btn peach-btn--ghost peach-btn--sm"656                        style="font-size:10px;padding:2px 8px;min-height:22px;"657                        onClick={() => setConfirmDelete(false)}658                      >659                        no660                      </button>661                    </div>662                  </Show>663                </Show>664              </div>665666              {/* ── Domain / URL ─────────────────────────────────── */}667              <Show when={entry().type !== "note" && entry().type !== "card"}>668                <div class="peach-domain" style="position:relative;z-index:1;">669                  <Show670                    when={editingField() === "url"}671                    fallback={672                      <div673                        class="peach-domain-row peach-inline-clickable"674                        title="Click to edit URL"675                        onClick={() => startEdit("url", entry().url ?? "")}676                      >677                        <DomainFavicon domain={displayDomain()} title={entry().title ?? ""} />678                        <span class="peach-domain-value">679                          {displayDomain() || (entry().url ? entry().url : "add a website url")}680                        </span>681                        <span class="peach-inline-edit-hint">edit</span>682                      </div>683                    }684                  >685                    <div686                      class="peach-inline-field-wrap peach-inline-field-wrap--url"687                      onFocusOut={(event) => handleEditorFocusOut("url", event)}688                    >689                      <div class="peach-inline-field-row">690                        <input691                          class="peach-inline-input"692                          type="url"693                          placeholder="https://"694                          value={draft()}695                          onInput={(e) => setDraft(e.currentTarget.value)}696                          onKeyDown={onEditKeyDown}697                          ref={autoFocus}698                          disabled={saving()}699                        />700                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />701                      </div>702                      <div class="peach-additional-urls">703                        <Show when={additionalUrlsDraft().length > 0}>704                          <span class="peach-label peach-additional-urls-label">also matches</span>705                        </Show>706                        <For each={additionalUrlsDraft()}>707                          {(value, i) => (708                            <div class="peach-additional-url-row">709                              <input710                                class="peach-inline-input"711                                type="url"712                                placeholder="https://"713                                value={value}714                                onInput={(e) => setAdditionalUrlsDraft((arr) => {715                                  const next = [...arr];716                                  next[i()] = e.currentTarget.value;717                                  return next;718                                })}719                                disabled={saving()}720                              />721                              <button722                                class="peach-additional-url-remove"723                                type="button"724                                aria-label="Remove URL"725                                onMouseDown={(e) => e.preventDefault()}726                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}727                                disabled={saving()}728                              >×</button>729                            </div>730                          )}731                        </For>732                        <button733                          class="peach-add-field-link peach-additional-urls-add"734                          type="button"735                          onMouseDown={(e) => e.preventDefault()}736                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}737                          disabled={saving()}738                        >739                          + add another url740                        </button>741                        <Show when={props.currentTabUrl && !urlMatchesAny(props.currentTabUrl!, draft(), additionalUrlsDraft())}>742                          <button743                            class="peach-add-field-link peach-additional-urls-add peach-additional-urls-add-current"744                            type="button"745                            title={props.currentTabUrl}746                            onMouseDown={(e) => e.preventDefault()}747                            onClick={() => addCurrentSite(props.currentTabUrl!)}748                            disabled={saving()}749                          >750                            + add current site751                          </button>752                        </Show>753                      </div>754                    </div>755                  </Show>756                </div>757              </Show>758759              {/* ── Tags — read-only chips (editing state lives at bottom) */}760              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>761                <div762                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"763                  onClick={startEditTags}764                  title="Click to edit tags"765                >766                  <For each={entry().tags!}>767                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}768                  </For>769                </div>770              </Show>771772              {/* ── Note toolbar + inline editor ─────────────────── */}773              <Show when={entry().type === "note"}>774                <div class="peach-entry-note-toolbar-row">775                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />776                </div>777                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">778                  <NoteEditorInline779                    entryKey={entry().id}780                    initialContent={entry().notes ?? ""}781                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}782                    onBlur={saveNoteInline}783                    autoFocus={false}784                  />785                </section>786              </Show>787788              {/* ── Card detail view ─────────────────────────────── */}789              <Show when={entry().type === "card"}>790                <section class="peach-fields" style="position:relative;z-index:1;">791                  {/* Card subtitle: •••• last4 */}792                  <Show when={entry().last4}>793                    <div class="peach-field-card" style="padding-bottom:4px;">794                      <span class="peach-label">795                        <Show when={entry().network && entry().network !== "unknown"}>796                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>797                        </Show>798                        {entry().cardNickname || `•••• ${entry().last4}`}799                      </span>800                    </div>801                  </Show>802803                  {/* Cardholder name */}804                  <div class="peach-field-card">805                    <span class="peach-label">cardholder name</span>806                    <div class="peach-field-value-row">807                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>808                      <Show when={entry().cardholderName}>809                        <button810                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}811                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}812                          aria-label="Copy cardholder name"813                        >814                          <CopyGlyph /><span class="peach-copy-sheen" />815                        </button>816                      </Show>817                    </div>818                  </div>819820                  {/* Card number */}821                  <div class="peach-field-card">822                    <span class="peach-label">card number</span>823                    <div class="peach-field-value-row">824                      <span class={`peach-field-value peach-card-number`}>825                        <Show when={showCardNumber()} fallback={826                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>827                        }>828                          <span>{entry().cardNumber ?? ""}</span>829                        </Show>830                      </span>831                      <div class="peach-field-actions">832                        <button833                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"834                          onClick={() => setShowCardNumber(!showCardNumber())}835                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}836                        >837                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>838                            <EyeClosedGlyph />839                          </Show>840                        </button>841                        <Show when={entry().cardNumber}>842                          <button843                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}844                            onClick={(e) => {845                              const cardNum = entry().cardNumber!;846                              if (entry().canary?.webhookUrl) {847                                void sendMessage({848                                  type: "FIRE_CANARY_PING",849                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },850                                });851                              }852                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);853                            }}854                            aria-label="Copy card number"855                          >856                            <CopyGlyph /><span class="peach-copy-sheen" />857                          </button>858                        </Show>859                      </div>860                    </div>861                  </div>862863                  {/* Expiry */}864                  <Show when={entry().expMonth || entry().expYear}>865                    <div class="peach-field-card">866                      <span class="peach-label">expiry</span>867                      <div class="peach-field-value-row">868                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>869                        <button870                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}871                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}872                          aria-label="Copy expiry"873                        >874                          <CopyGlyph /><span class="peach-copy-sheen" />875                        </button>876                      </div>877                    </div>878                  </Show>879880                  {/* CVC */}881                  <Show when={entry().cvc}>882                    <div class="peach-field-card">883                      <span class="peach-label">CVC</span>884                      <div class="peach-field-value-row">885                        <span class="peach-field-value">886                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>887                            <span>{entry().cvc}</span>888                          </Show>889                        </span>890                        <div class="peach-field-actions">891                          <button892                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"893                            onClick={() => setShowCardCvc(!showCardCvc())}894                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}895                          >896                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>897                              <EyeClosedGlyph />898                            </Show>899                          </button>900                          <button901                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}902                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}903                            aria-label="Copy CVC"904                          >905                            <CopyGlyph /><span class="peach-copy-sheen" />906                          </button>907                        </div>908                      </div>909                    </div>910                  </Show>911                </section>912              </Show>913914              {/* ── Login fields ─────────────────────────────────── */}915              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>916                <section class="peach-fields" style="position:relative;z-index:1;">917918                  {/* Username — primary identity slot. Auto-labels as "email"919                      when the stored value is email-shaped and no separate920                      email field exists (legacy data path). */}921                  <Show when={entry().username || (!entry().username && !entry().email) || editingField() === "username"}>922                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>923                      <span class="peach-label">{entry().username?.includes("@") && !entry().email ? "email" : "username"}</span>924                      <Show925                        when={editingField() === "username"}926                        fallback={927                          <div class="peach-field-value-row">928                            <span929                              class="peach-field-value peach-inline-clickable"930                              title="Click to edit"931                              onClick={() => startEdit("username", entry().username ?? "")}932                            >933                              {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}934                            </span>935                            <Show when={entry().username}>936                              <button937                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}938                                onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}939                                aria-label="Copy username"940                              >941                                <CopyGlyph /><span class="peach-copy-sheen" />942                              </button>943                            </Show>944                          </div>945                        }946                      >947                        <div948                          class="peach-inline-field-wrap"949                          onFocusOut={(event) => handleEditorFocusOut("username", event)}950                        >951                          <input952                            class="peach-inline-input"953                            value={draft()}954                            onInput={(e) => setDraft(e.currentTarget.value)}955                            onKeyDown={(e) => identityKeyDown(e, "username")}956                            ref={(el) => { usernameInlineInput = el; autoFocus(el); }}957                            disabled={saving()}958                            autocomplete="off"959                          />960                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />961                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "username"}>962                            <IdentitySuggestList963                              listRef={(el) => (usernameIdentityEl = el)}964                              inputRef={() => usernameInlineInput}965                              suggestions={identitySuggestions()}966                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}967                              onClose={() => { setIdentityHelperSuppressed(true); usernameInlineInput?.focus(); }}968                            />969                          </Show>970                          {/* +add the OTHER identity. If the slot is acting as971                              email (value has @, no separate email), offer972                              "+ add username" — clicking migrates the email973                              into entry.email. Otherwise offer "+ add email". */}974                          <Show when={!entry().email || !(entry().username && !entry().username?.includes("@"))}>975                            <button976                              class="peach-add-field-btn"977                              type="button"978                              onMouseDown={(e) => e.preventDefault()}979                              onClick={() => {980                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);981                                const hasSeparateEmail = !!entry().email;982                                if (isEmailShape && !hasSeparateEmail) {983                                  void addOtherIdentity("username");984                                } else {985                                  void addOtherIdentity("email");986                                }987                              }}988                            >989                              {(() => {990                                const isEmailShape = draft().includes("@") || (entry().username?.includes("@") ?? false);991                                const hasSeparateEmail = !!entry().email;992                                return (isEmailShape && !hasSeparateEmail) || hasSeparateEmail ? "+ add username" : "+ add email";993                              })()}994                            </button>995                          </Show>996                        </div>997                      </Show>998                    </div>999                  </Show>10001001                  {/* Email — secondary identity slot. Visible whenever a1002                      separate email field is set, or while it is being edited. */}1003                  <Show when={entry().email || editingField() === "email"}>1004                    <div class="peach-field-card" classList={{ "is-editing": editingField() === "email" }}>1005                      <span class="peach-label">email</span>1006                      <Show1007                        when={editingField() === "email"}1008                        fallback={1009                          <div class="peach-field-value-row">1010                            <span1011                              class="peach-field-value peach-inline-clickable"1012                              title="Click to edit"1013                              onClick={() => startEdit("email", entry().email ?? "")}1014                            >1015                              {entry().email}1016                            </span>1017                            <Show when={entry().email}>1018                              <button1019                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "email" ? "is-copied" : ""}`}1020                                onClick={(e) => void copyField(entry().email!, "email", e.currentTarget as HTMLButtonElement)}1021                                aria-label="Copy email"1022                              >1023                                <CopyGlyph /><span class="peach-copy-sheen" />1024                              </button>1025                            </Show>1026                          </div>1027                        }1028                      >1029                        <div1030                          class="peach-inline-field-wrap"1031                          onFocusOut={(event) => handleEditorFocusOut("email", event)}1032                        >1033                          <input1034                            class="peach-inline-input"1035                            value={draft()}1036                            onInput={(e) => setDraft(e.currentTarget.value)}1037                            onKeyDown={(e) => identityKeyDown(e, "email")}1038                            ref={(el) => { emailInlineInput = el; autoFocus(el); }}1039                            disabled={saving()}1040                            autocomplete="email"1041                            type="email"1042                          />1043                          <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1044                          <Show when={!identityHelperSuppressed() && identitySuggestions().length > 0 && editingField() === "email"}>1045                            <IdentitySuggestList1046                              listRef={(el) => (emailIdentityEl = el)}1047                              inputRef={() => emailInlineInput}1048                              suggestions={identitySuggestions()}1049                              onPick={(v) => { setDraft(v); setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1050                              onClose={() => { setIdentityHelperSuppressed(true); emailInlineInput?.focus(); }}1051                            />1052                          </Show>1053                          <Show when={!entry().username || entry().username?.includes("@")}>1054                            <button1055                              class="peach-add-field-btn"1056                              type="button"1057                              onMouseDown={(e) => e.preventDefault()}1058                              onClick={() => void addOtherIdentity("username")}1059                            >1060                              + add username1061                            </button>1062                          </Show>1063                        </div>1064                      </Show>1065                    </div>1066                  </Show>10671068                  {/* Password */}1069                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>1070                    <span class="peach-label">password</span>1071                    <Show1072                      when={editingField() === "password"}1073                      fallback={1074                        <div class="peach-field-value-row">1075                          <span1076                            class="peach-password-value"1077                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}1078                            style="cursor:pointer;"1079                            title="Click to edit password"1080                            onClick={() => startEdit("password", entry().password ?? "")}1081                          >1082                            <Show1083                              when={showPassword()}1084                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}1085                            >1086                              <span class="peach-password-text">{entry().password ?? ""}</span>1087                            </Show>1088                          </span>1089                          <div class="peach-field-actions">1090                            <button1091                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1092                              onClick={() => setShowPassword(!showPassword())}1093                              aria-label="Reveal password"1094                            >1095                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1096                                <EyeClosedGlyph />1097                              </Show>1098                            </button>1099                            <button1100                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}1101                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}1102                              aria-label="Copy password"1103                            >1104                              <CopyGlyph /><span class="peach-copy-sheen" />1105                            </button>1106                          </div>1107                        </div>1108                      }1109                    >1110                      {/* ── Edit mode: type / paste / generate ── */}1111                      <div1112                        class="peach-inline-field-wrap"1113                        onFocusOut={(event) => handleEditorFocusOut("password", event)}1114                      >1115                        <div class="peach-password-edit-row">1116                          <input1117                            class="peach-inline-input"1118                            type={showPassword() ? "text" : "password"}1119                            placeholder="paste or type a password"1120                            value={draft()}1121                            onInput={(e) => setDraft(e.currentTarget.value)}1122                            onKeyDown={(e) => {1123                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }1124                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }1125                            }}1126                            ref={autoFocus}1127                            disabled={saving()}1128                            autocomplete="new-password"1129                          />1130                          <button1131                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1132                            type="button"1133                            onClick={() => setShowPassword(!showPassword())}1134                            aria-label={showPassword() ? "Hide" : "Show"}1135                          >1136                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>1137                              <EyeClosedGlyph />1138                            </Show>1139                          </button>1140                        </div>1141                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1142                        <button1143                          class="peach-generate-toggle-link"1144                          type="button"1145                          onClick={() => setShowGenerator(!showGenerator())}1146                        >1147                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">1148                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />1149                            <path d="M9.5 2.5H11V1" />1150                          </svg>1151                          {showGenerator() ? "hide generator" : "generate"}1152                        </button>1153                        <Show when={showGenerator()}>1154                          <GeneratorDrawer1155                            open={true}1156                            compact={true}1157                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}1158                          />1159                        </Show>1160                      </div>1161                    </Show>1162                  </div>11631164                </section>1165              </Show>11661167              {/* ── Notes (on login entries) ──────────────────────── */}1168              <Show when={entry().type !== "note" && entry().type !== "card"}>1169                <Show1170                  when={entry().notes || editingField() === "notes"}1171                  fallback={1172                    <button1173                      class="peach-add-field-link peach-entry-add-link"1174                      onClick={() => startEdit("notes", "")}1175                    >1176                      + notes1177                    </button>1178                  }1179                >1180                  <div style="position:relative;z-index:1;margin-top:6px;">1181                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>1182                    <Show1183                      when={editingField() === "notes"}1184                      fallback={1185                        <div1186                          class="peach-note-content peach-inline-clickable"1187                          style="cursor:text;white-space:pre-wrap;"1188                          onClick={() => startEdit("notes", entry().notes ?? "")}1189                        >1190                          {entry().notes}1191                        </div>1192                      }1193                    >1194                      <div1195                        class="peach-inline-field-wrap"1196                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}1197                      >1198                        <textarea1199                          class="peach-inline-textarea"1200                          value={draft()}1201                          onInput={(e) => setDraft(e.currentTarget.value)}1202                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}1203                          ref={(el) => requestAnimationFrame(() => el?.focus())}1204                          rows={4}1205                          disabled={saving()}1206                        />1207                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1208                      </div>1209                    </Show>1210                  </div>1211                </Show>1212              </Show>12131214              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}1215              <Show1216                when={editingTags()}1217                fallback={1218                  <Show when={!entry().tags || entry().tags!.length === 0}>1219                    <button1220                      class="peach-add-field-link peach-entry-add-link"1221                      onClick={startEditTags}1222                    >1223                      + tags1224                    </button>1225                  </Show>1226                }1227              >1228                <div1229                  class="peach-inline-tag-editor"1230                  onFocusOut={(e) => {1231                    const next = e.relatedTarget;1232                    if (next instanceof Node && e.currentTarget.contains(next)) return;1233                    void saveTags();1234                  }}1235                >1236                  <span class="peach-label">tags</span>1237                  <div class="peach-tag-input-wrap">1238                    <For each={tagsDraft()}>1239                      {(tag) => (1240                        <span class="peach-tag-chip">1241                          {tag}1242                          <button1243                            class="peach-tag-chip-remove"1244                            onMouseDown={(e) => e.preventDefault()}1245                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1246                            aria-label={`Remove ${tag}`}1247                          >×</button>1248                        </span>1249                      )}1250                    </For>1251                    <input1252                      class="peach-tag-input"1253                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1254                      value={tagInput()}1255                      onInput={(e) => setTagInput(e.currentTarget.value)}1256                      onKeyDown={(e) => {1257                        if (e.key === "Enter" || e.key === ",") {1258                          e.preventDefault();1259                          addTag(tagInput());1260                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1261                          setTagsDraft(tagsDraft().slice(0, -1));1262                        } else if (e.key === "Escape") {1263                          e.preventDefault();1264                          setEditingTags(false);1265                          setTagInput("");1266                        }1267                      }}1268                      ref={(el) => requestAnimationFrame(() => el?.focus())}1269                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1270                    />1271                  </div>1272                </div>1273              </Show>12741275              {/* ── TOTP ─────────────────────────────────────────── */}1276              <Show when={entry().type === "login"}>1277                <Show1278                  when={totpState() || editingField() === "totp"}1279                  fallback={1280                    <button1281                      class="peach-add-field-link peach-entry-add-link"1282                      onClick={() => startEdit("totp", "")}1283                    >1284                      + 2FA1285                    </button>1286                  }1287                >1288                  <section class="peach-totp-band">1289                    <Show1290                      when={editingField() === "totp"}1291                      fallback={1292                        <Show when={totpState()}>1293                          {(state) => (1294                            <>1295                              <div1296                                class="peach-totp-main"1297                                onClick={() => {1298                                  // Delay copy so a double-click can cancel it before1299                                  // it overwrites the clipboard the user wants to paste from.1300                                  if (totpClickTimer) return;1301                                  totpClickTimer = setTimeout(() => {1302                                    totpClickTimer = null;1303                                    void copyField(state().code.replace(" ", ""), "totp");1304                                  }, 220);1305                                }}1306                                onDblClick={() => {1307                                  if (totpClickTimer) { clearTimeout(totpClickTimer); totpClickTimer = null; }1308                                  startEdit("totp", entry().totp ?? "");1309                                }}1310                                title="Click to copy · double-click to edit"1311                              >1312                                <span class="peach-label">2-factor</span>1313                                <div class="peach-totp-code-row">1314                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1315                                    <span class="peach-totp-code-text">1316                                      {state().code.slice(0, 3)} {state().code.slice(3)}1317                                    </span>1318                                  </p>1319                                </div>1320                              </div>1321                              <div class="peach-totp-meta">1322                                <span class="peach-label">expires</span>1323                                <div class="peach-totp-countdown">{state().remaining}s</div>1324                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1325                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1326                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1327                                    stroke-dasharray="106.8"1328                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1329                                  />1330                                </svg>1331                              </div>1332                            </>1333                          )}1334                        </Show>1335                      }1336                    >1337                      <div1338                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1339                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1340                      >1341                        <span class="peach-label">2-factor</span>1342                        <input1343                          class="peach-inline-input"1344                          value={draft()}1345                          onInput={(e) => setDraft(e.currentTarget.value)}1346                          onKeyDown={onEditKeyDown}1347                          ref={autoFocus}1348                          disabled={saving()}1349                          placeholder="otpauth://... or base32 secret"1350                          autocomplete="off"1351                        />1352                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1353                      </div>1354                    </Show>1355                  </section>1356                </Show>1357              </Show>13581359              {/* ── Advanced (canary + passkey) ───────────────────── */}1360              <Show when={entry().type === "login"}>1361                <button1362                  class="peach-add-field-link peach-entry-add-link"1363                  onClick={() => setShowAdvanced((v) => !v)}1364                  aria-expanded={showAdvanced()}1365                >1366                  {showAdvanced() ? "− Advanced" : "+ Advanced"}1367                </button>1368                <Show when={showAdvanced()}>1369                  <Show when={!isPasskeyOnly(entry())}>1370                    <CanarySection entry={entry()} onSaved={props.onFieldSaved} isPro={props.isPro ?? false} onProGate={props.onProGate} />1371                  </Show>13721373                  <Show when={entry().passkey}>1374                    {(passkey) => {1375                      const currentBrowser = detectBrowser();1376                      const crossBrowser =1377                        passkey().browser &&1378                        currentBrowser !== "unknown" &&1379                        passkey().browser !== currentBrowser;1380                      const algorithmLabel =1381                        passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1382                      const formatDate = (ts?: number) =>1383                        ts ? new Date(ts).toLocaleDateString() : "Never";1384                      return (1385                        <section1386                          class="peach-fields peach-passkey-section"1387                          style="position:relative;z-index:1;margin-top:6px;"1388                        >1389                          <div class="peach-field-card">1390                            <span class="peach-label">passkey · {passkey().rpId}</span>1391                            <div class="peach-field-value-row">1392                              <span class="peach-field-value">{passkey().userName}</span>1393                              <span class="peach-passkey-badge">passkey</span>1394                            </div>1395                          </div>13961397                          <div class="peach-field-card">1398                            <span class="peach-label">algorithm</span>1399                            <div class="peach-field-value-row">1400                              <span class="peach-field-value">{algorithmLabel}</span>1401                            </div>1402                          </div>14031404                          <div class="peach-field-card">1405                            <span class="peach-label">activity</span>1406                            <div class="peach-field-value-row peach-passkey-activity">1407                              <span class="peach-field-value">1408                                used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1409                              </span>1410                              <span class="peach-passkey-meta">1411                                created {formatDate(passkey().createdAt)}1412                              </span>1413                              <span class="peach-passkey-meta">1414                                last used {formatDate(passkey().lastUsedAt)}1415                              </span>1416                            </div>1417                          </div>14181419                          <Show when={crossBrowser}>1420                            <div class="peach-info-notice peach-info-notice--warn">1421                              {`This passkey was created on ${1422                                passkey().browser === "chrome" ? "Chrome" : "Firefox"1423                              }. To use it on ${1424                                currentBrowser === "chrome" ? "Chrome" : "Firefox"1425                              }, re-register it on this device.`}1426                            </div>1427                          </Show>14281429                          <div class="peach-info-notice">1430                            {passkey().browser === "firefox"1431                              ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1432                              : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1433                          </div>14341435                          <button1436                            class="peach-add-field-link peach-entry-add-link"1437                            style="color:rgba(220,38,38,0.7);"1438                            onClick={async () => {1439                              const id = entry().id;1440                              await sendMessage({1441                                type: "UPDATE_ENTRY",1442                                payload: { id, patch: { passkey: undefined } },1443                              });1444                              await props.onFieldSaved();1445                            }}1446                          >1447                            − remove passkey1448                          </button>1449                        </section>1450                      );1451                    }}1452                  </Show>1453                </Show>1454              </Show>14551456              {/* ── Bottom spacer ─────────────────────────────────── */}1457              <div style="height:4px;" />1458            </>1459          )}1460        </Show>1461      </div>14621463    </>1464  );1465}14661467// ── Sub-components ────────────────────────────────────────────────────14681469function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1470  return (1471    <div class="peach-inline-actions">1472      <button1473        class="peach-inline-save-btn"1474        onClick={props.onSave}1475        disabled={props.saving}1476        aria-label="Save"1477        title="Save (Enter)"1478      >1479        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1480          <path d="M1 4.5L4 7.5L10 1.5" />1481        </svg>1482      </button>1483      <button1484        class="peach-inline-cancel-btn"1485        onClick={props.onCancel}1486        disabled={props.saving}1487        aria-label="Cancel"1488        title="Cancel (Esc)"1489      >1490        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1491          <path d="M1 1L8 8M8 1L1 8" />1492        </svg>1493      </button>1494    </div>1495  );1496}14971498function EmptyVaultState() {1499  return (1500    <>1501      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1502        <div class="peach-entry-name">1503          <span class="peach-entry-name-text">1504            <span class="peach-entry-name-char" style="--char-index:0">e</span>1505            <span class="peach-entry-name-char" style="--char-index:1">m</span>1506            <span class="peach-entry-name-char" style="--char-index:2">p</span>1507            <span class="peach-entry-name-char" style="--char-index:3">t</span>1508            <span class="peach-entry-name-char" style="--char-index:4">y</span>1509          </span>1510        </div>1511      </div>1512      <div class="peach-empty-copy">1513        <span class="peach-label">vault</span>1514        <p>Your vault is ready for its first entry.</p>1515      </div>1516    </>1517  );1518}15191520function CopyGlyph() {1521  return (1522    <span class="peach-copy-icon">1523      <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">1524        <rect x="4" y="4" width="8" height="9" rx="1.5" />1525        <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" />1526      </svg>1527    </span>1528  );1529}15301531function EyeClosedGlyph() {1532  return (1533    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1534      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1535      <path d="M3.5 5.5L2.5 7.5" />1536      <path d="M7 6.5V8.5" />1537      <path d="M10.5 5.5L11.5 7.5" />1538    </svg>1539  );1540}15411542function EyeOpenGlyph() {1543  return (1544    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1545      <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" />1546      <circle cx="7" cy="5" r="1.75" />1547    </svg>1548  );1549}15501551function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void>; isPro: boolean; onProGate?: (feature: ProFeature) => void }) {1552  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1553  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1554  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1555  const [testResult, setTestResult] = createSignal<string | null>(null);1556  const [testing, setTesting] = createSignal(false);15571558  // Pro gate entry-point for canary creation. Free users cannot toggle the1559  // canary on; the server-side UPDATE_ENTRY handler is the single hard1560  // enforcement point, but this branch avoids the bad UX of letting the1561  // toggle slip into "on" before the save rejects. Existing canary entries1562  // (e.g. retained after a license deactivation, future-proofing) remain1563  // togglable off — gating only applies to enabling a new canary.1564  const isProGated = () => !props.isPro && !props.entry.canary;15651566  async function save() {1567    const url = webhookUrl().trim();1568    const nextLabel = label().trim() || undefined;1569    const canary = enabled() && url1570      ? {1571          webhookUrl: url,1572          label: nextLabel,1573          lastPinged: props.entry.canary?.lastPinged,1574          pingCount: props.entry.canary?.pingCount,1575        }1576      : undefined;1577    await sendMessage({1578      type: "UPDATE_ENTRY",1579      payload: { id: props.entry.id, patch: { canary } },1580    });1581    await props.onSaved();1582  }15831584  async function testPing() {1585    const url = webhookUrl().trim();1586    if (!url) return;1587    setTesting(true);1588    setTestResult(null);1589    const res = await sendMessage({1590      type: "FIRE_CANARY_PING",1591      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1592    }) as { ok: boolean; error?: string };1593    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1594    setTesting(false);1595  }15961597  return (1598    <div class="peach-canary-section">1599      <div class="peach-field-value-row">1600        <span class="peach-field-value" style="font-size:12px;">1601          canary alert1602          <Show when={isProGated()}>1603            <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1604          </Show>1605        </span>1606        <button1607          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1608          onClick={() => {1609            if (isProGated()) {1610              // Pro gate: tap on free tier navigates to the upgrade screen1611              // with Canary Entries highlighted. The toggle never flips on1612              // for a free user, regardless of click count.1613              props.onProGate?.("canary_entry");1614              return;1615            }1616            setEnabled(!enabled());1617            void save();1618          }}1619          aria-label="Toggle canary"1620          aria-disabled={isProGated() ? "true" : "false"}1621        >1622          <span class="peach-toggle-knob" />1623        </button>1624      </div>1625      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1626        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1627      </p>1628      <Show when={enabled()}>1629        <div class="peach-inline-field-wrap" style="margin-top:8px;">1630          <input1631            class="peach-inline-input"1632            placeholder="https://hooks.zapier.com/..."1633            value={webhookUrl()}1634            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1635            onBlur={() => void save()}1636          />1637          <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1638            When triggered, the entry title is sent to this URL. Use an endpoint you control.1639          </p>1640          <input1641            class="peach-inline-input"1642            placeholder="label (optional, e.g. AWS decoy)"1643            value={label()}1644            onInput={(e) => setLabel(e.currentTarget.value)}1645            onBlur={() => void save()}1646          />1647          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1648            <button1649              class="peach-btn peach-btn--ghost peach-btn--sm"1650              onClick={() => void testPing()}1651              disabled={testing() || !webhookUrl().trim()}1652            >1653              {testing() ? "sending..." : "test ping"}1654            </button>1655            <Show when={testResult()}>1656              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1657            </Show>1658          </div>1659          <Show when={props.entry.canary?.lastPinged}>1660            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1661              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1662              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1663            </p>1664          </Show>1665        </div>1666      </Show>1667    </div>1668  );1669}