Entry.tsx

46.7 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { popupCopy } from "../copy";8import { GeneratorDrawer } from "../components/GeneratorDrawer";9import { NoteEditorInline, Toolbar } from "./NoteEditor";1011type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1213interface EntryProps {14  entry: VaultEntry | null;15  onDelete: () => void;16  /** Called after any inline save so App can reload entries */17  onFieldSaved: () => void | Promise<void>;18  onEscape?: () => void;19}2021export function EntryView(props: EntryProps) {22  const [editingField, setEditingField] = createSignal<EditableField>(null);23  const [draft, setDraft] = createSignal("");24  const [saving, setSaving] = createSignal(false);25  const [showPassword, setShowPassword] = createSignal(false);26  const [showGenerator, setShowGenerator] = createSignal(false);27  const [copied, setCopied] = createSignal<string | null>(null);28  const [showCardNumber, setShowCardNumber] = createSignal(false);29  const [showCardCvc, setShowCardCvc] = createSignal(false);30  const [totpTick, setTotpTick] = createSignal(0);31  const [confirmDelete, setConfirmDelete] = createSignal(false);32  const [totpHot, setTotpHot] = createSignal(false);33  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);34  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);35  let lastSavedNote: string | null = null;3637  function noteExec(cmd: string, val?: string) {38    document.execCommand(cmd, false, val);39    noteEditorEl()?.focus();40  }4142  async function saveNoteInline(html: string) {43    const entry = props.entry;44    if (!entry) return;45    const currentStored = entry.notes ?? "";46    if (html === currentStored) return;47    if (lastSavedNote !== null && html === lastSavedNote) return;48    lastSavedNote = html;49    setSaving(true);50    try {51      await sendMessage({52        type: "UPDATE_ENTRY",53        payload: { id: entry.id, patch: { notes: html } },54      });55      triggerRing();56      await props.onFieldSaved();57    } catch {58      lastSavedNote = null;59    } finally {60      setSaving(false);61    }62  }6364  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);65  onCleanup(() => window.clearInterval(interval));6667  const displayDomain = createMemo(() =>68    props.entry?.url ? getDisplayDomain(props.entry.url) : ""69  );7071  createEffect(() => {72    const domain = displayDomain();73    setFaviconSrc(domain ? faviconUrl(domain) : null);74  });7576  const totpState = createMemo(() => {77    void totpTick();78    if (!props.entry?.totp) return null;79    return generateTOTP(props.entry.totp);80  });8182  let prevTotpCode: string | undefined;83  createEffect(() => {84    const state = totpState();85    if (!state) { prevTotpCode = undefined; return; }86    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {87      setTotpHot(true);88      window.setTimeout(() => setTotpHot(false), 3000);89    }90    prevTotpCode = state.code;91  });9293  const totpProgress = createMemo(() => {94    const state = totpState();95    if (!state) return 0;96    return ((state.remaining - 1) / 29) * 100;97  });9899  createEffect(() => {100    const shell = document.getElementById("hd-shell");101    if (!shell) return;102103    const state = totpState();104    if (!state) {105      delete shell.dataset.totpActive;106      shell.style.removeProperty("--peach-totp-progress");107      return;108    }109110    shell.dataset.totpActive = "true";111    shell.style.setProperty(112      "--peach-totp-progress",113      `${Math.max(0, totpProgress())}%`114    );115116    onCleanup(() => {117      delete shell.dataset.totpActive;118      shell.style.removeProperty("--peach-totp-progress");119    });120  });121122  const titleSizeClass = createMemo(() => {123    const length = props.entry?.title.length ?? 0;124    if (length >= 28) return "peach-entry-name--xxl";125    if (length >= 20) return "peach-entry-name--xl";126    if (length >= 14) return "peach-entry-name--lg";127    return "";128  });129  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);130131  // ── Inline editing ────────────────────────────────────────────────132133  function startEdit(field: EditableField, value: string) {134    setEditingField(field);135    setDraft(value);136    setShowGenerator(false);137    setConfirmDelete(false);138  }139140  async function saveField() {141    const field = editingField();142    const entry = props.entry;143    if (!field || !entry) return;144    // Don't trim passwords — spaces may be intentional145    let value = field === "password" ? draft() : draft().trim();146    if (field === "totp" && value.startsWith("otpauth://")) {147      const parsed = parseOtpauthURI(value);148      value = parsed?.secret ?? value;149    }150    if (field === "password" && entry.type !== "note" && !value) {151      return;152    }153    setSaving(true);154    try {155      await sendMessage({156        type: "UPDATE_ENTRY",157        payload: { id: entry.id, patch: { [field]: value } },158      });159      triggerRing();160      setEditingField(null);161      setShowGenerator(false);162      await props.onFieldSaved();163    } catch {164      // silent — leave editing open165    } finally {166      setSaving(false);167    }168  }169170  function cancelEdit() {171    setEditingField(null);172    setDraft("");173    setShowGenerator(false);174  }175176  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {177    switch (field) {178      case "title":179        return entry.title;180      case "url":181        return entry.url ?? "";182      case "username":183        return entry.username ?? "";184      case "password":185        return entry.password ?? "";186      case "notes":187        return entry.notes ?? "";188      case "totp":189        return entry.totp ?? "";190    }191  }192193  function handleEditorFocusOut(194    field: Exclude<EditableField, null>,195    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }196  ) {197    const nextTarget = event.relatedTarget;198    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;199200    const entry = props.entry;201    if (!entry) return;202203    const currentValue = fieldValue(entry, field);204    const nextValue = field === "password" ? draft() : draft().trim();205    if (nextValue === currentValue) {206      cancelEdit();207    }208  }209210  async function savePassword(password: string) {211    const entry = props.entry;212    if (!entry) return;213    setSaving(true);214    try {215      await sendMessage({216        type: "UPDATE_ENTRY",217        payload: { id: entry.id, patch: { password } },218      });219      triggerRing();220      setShowGenerator(false);221      await props.onFieldSaved();222    } catch {223      // silent224    } finally {225      setSaving(false);226    }227  }228229  // ── Copy ─────────────────────────────────────────────────────────230231  async function copyField(text: string, label: string, button?: HTMLButtonElement) {232    const ok = await popupCopy(text, label);233    if (ok) {234      const entry = props.entry;235      if (entry?.canary?.webhookUrl) {236        const trigger =237          label === "password"238            ? "password_copied"239            : label === "username"240              ? "username_copied"241              : undefined;242        if (trigger) {243          void sendMessage({244            type: "FIRE_CANARY_PING",245            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },246          });247        }248      }249      triggerRing();250      triggerPipPulse();251      setCopied(null);252      requestAnimationFrame(() => setCopied(label));253      if (button) {254        button.classList.remove("is-copied", "is-retreat");255        void button.offsetWidth;256        const drain = document.createElement("span");257        drain.className = "peach-copy-drain";258        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";259        drain.appendChild(bar);260        button.appendChild(drain);261        window.setTimeout(() => {262          button.classList.remove("is-copied");263          button.classList.add("is-retreat");264          drain.remove();265          window.setTimeout(() => button.classList.remove("is-retreat"), 160);266        }, 1800);267      }268      window.setTimeout(() => setCopied(null), 1800);269    }270  }271272  // ── Keyboard shortcuts ────────────────────────────────────────────273274  createEffect(() => {275    if (!props.entry) return;276    const handler = (e: KeyboardEvent) => {277      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;278      if (inInput) return;279      switch (e.key) {280        case "c": e.preventDefault();281          if (props.entry?.password) void copyField(props.entry.password, "password"); break;282        case "u": e.preventDefault();283          if (props.entry?.username) void copyField(props.entry.username, "username"); break;284        case "Escape": e.preventDefault(); props.onEscape?.(); break;285      }286    };287    window.addEventListener("keydown", handler);288    onCleanup(() => window.removeEventListener("keydown", handler));289  });290291  // ── Input ref focus helper ────────────────────────────────────────292293  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {294    requestAnimationFrame(() => { el.focus(); el.select?.(); });295  }296297  function onEditKeyDown(e: KeyboardEvent) {298    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {299      e.preventDefault();300      void saveField();301    }302    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }303  }304305  return (306    <>307      <div308        class="peach-ghost"309        aria-hidden="true"310        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;"311      >312        {props.entry?.title || "empty"}313      </div>314315      <div class="peach-stage" style="position:relative;">316        <Show when={props.entry} fallback={<EmptyVaultState />}>317          {(entry) => (318            <>319              {/* ── Breach strip ─────────────────────────────────── */}320              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>321                <div class="peach-breach-badge">322                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">323                    <path d="M6 1L11 10H1L6 1z" />324                    <line x1="6" y1="5" x2="6" y2="7.5" />325                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />326                  </svg>327                  <span style="flex:1">found in a known data breach</span>328                  <button329                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"330                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}331                  >332                    Change password333                  </button>334                </div>335              </Show>336337              {/* ── Title + trash ─────────────────────────────────── */}338              <div class="peach-entry-header">339                <div class="peach-entry-header-name">340                  <Show341                    when={editingField() === "title"}342                    fallback={343                      <button344                        class="peach-entry-name-button"345                        title="Click to edit title"346                        onClick={() => startEdit("title", entry().title)}347                      >348                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>349                          <span class="peach-entry-name-text">350                            <Show351                              when={useAnimatedTitle()}352                              fallback={entry().title}353                            >354                              <For each={entry().title.split("")}>355                                {(ch, i) => (356                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>357                                    {ch === " " ? "\u00A0" : ch}358                                  </span>359                                )}360                              </For>361                            </Show>362                          </span>363                        </h1>364                      </button>365                    }366                  >367                    <div368                      class="peach-inline-title-wrap"369                      onFocusOut={(event) => handleEditorFocusOut("title", event)}370                    >371                      <input372                        class="peach-inline-title-input"373                        value={draft()}374                        onInput={(e) => setDraft(e.currentTarget.value)}375                        onKeyDown={onEditKeyDown}376                        ref={autoFocus}377                        disabled={saving()}378                      />379                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />380                    </div>381                  </Show>382                </div>383384                {/* Subtle trash icon — hidden while editing title */}385                <Show when={editingField() !== "title"}>386                  <Show387                    when={confirmDelete()}388                    fallback={389                      <button390                        class="peach-entry-trash-btn"391                        onClick={() => setConfirmDelete(true)}392                        aria-label="Move to trash"393                        title="Delete entry"394                      >395                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">396                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />397                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />398                        </svg>399                      </button>400                    }401                  >402                    <div class="peach-entry-trash-confirm">403                      <span class="peach-entry-trash-confirm-label">trash?</span>404                      <button405                        class="peach-btn peach-btn--sm peach-btn--danger"406                        style="font-size:10px;padding:2px 8px;min-height:22px;"407                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}408                      >409                        yes410                      </button>411                      <button412                        class="peach-btn peach-btn--ghost peach-btn--sm"413                        style="font-size:10px;padding:2px 8px;min-height:22px;"414                        onClick={() => setConfirmDelete(false)}415                      >416                        no417                      </button>418                    </div>419                  </Show>420                </Show>421              </div>422423              {/* ── Domain / URL ─────────────────────────────────── */}424              <Show when={entry().type !== "note" && entry().type !== "card"}>425                <div class="peach-domain" style="position:relative;z-index:1;">426                  <Show427                    when={editingField() === "url"}428                    fallback={429                      <div430                        class="peach-domain-row peach-inline-clickable"431                        title="Click to edit URL"432                        onClick={() => startEdit("url", entry().url ?? "")}433                      >434                        <Show when={faviconSrc()}>435                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />436                        </Show>437                        <span class="peach-domain-value">438                          {displayDomain() || (entry().url ? entry().url : "add a website url")}439                        </span>440                        <span class="peach-inline-edit-hint">edit</span>441                      </div>442                    }443                  >444                    <div445                      class="peach-inline-field-wrap"446                      onFocusOut={(event) => handleEditorFocusOut("url", event)}447                    >448                      <input449                        class="peach-inline-input"450                        type="url"451                        placeholder="https://"452                        value={draft()}453                        onInput={(e) => setDraft(e.currentTarget.value)}454                        onKeyDown={onEditKeyDown}455                        ref={autoFocus}456                        disabled={saving()}457                      />458                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />459                    </div>460                  </Show>461                </div>462              </Show>463464              {/* ── Tags ─────────────────────────────────────────── */}465              <Show when={entry().tags && entry().tags!.length > 0}>466                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">467                  <For each={entry().tags!}>468                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}469                  </For>470                </div>471              </Show>472473              {/* ── Note toolbar + inline editor ─────────────────── */}474              <Show when={entry().type === "note"}>475                <div class="peach-entry-note-toolbar-row">476                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />477                </div>478                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">479                  <NoteEditorInline480                    entryKey={entry().id}481                    initialContent={entry().notes ?? ""}482                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}483                    onBlur={saveNoteInline}484                    autoFocus={false}485                  />486                </section>487              </Show>488489              {/* ── Card detail view ─────────────────────────────── */}490              <Show when={entry().type === "card"}>491                <section class="peach-fields" style="position:relative;z-index:1;">492                  {/* Card subtitle: •••• last4 */}493                  <Show when={entry().last4}>494                    <div class="peach-field-card" style="padding-bottom:4px;">495                      <span class="peach-label">496                        <Show when={entry().network && entry().network !== "unknown"}>497                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>498                        </Show>499                        {entry().cardNickname || `•••• ${entry().last4}`}500                      </span>501                    </div>502                  </Show>503504                  {/* Cardholder name */}505                  <div class="peach-field-card">506                    <span class="peach-label">cardholder name</span>507                    <div class="peach-field-value-row">508                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>509                      <Show when={entry().cardholderName}>510                        <button511                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}512                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}513                          aria-label="Copy cardholder name"514                        >515                          <CopyGlyph /><span class="peach-copy-sheen" />516                        </button>517                      </Show>518                    </div>519                  </div>520521                  {/* Card number */}522                  <div class="peach-field-card">523                    <span class="peach-label">card number</span>524                    <div class="peach-field-value-row">525                      <span class={`peach-field-value peach-card-number`}>526                        <Show when={showCardNumber()} fallback={527                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>528                        }>529                          <span>{entry().cardNumber ?? ""}</span>530                        </Show>531                      </span>532                      <div class="peach-field-actions">533                        <button534                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"535                          onClick={() => setShowCardNumber(!showCardNumber())}536                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}537                        >538                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>539                            <EyeClosedGlyph />540                          </Show>541                        </button>542                        <Show when={entry().cardNumber}>543                          <button544                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}545                            onClick={(e) => {546                              const cardNum = entry().cardNumber!;547                              if (entry().canary?.webhookUrl) {548                                void sendMessage({549                                  type: "FIRE_CANARY_PING",550                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },551                                });552                              }553                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);554                            }}555                            aria-label="Copy card number"556                          >557                            <CopyGlyph /><span class="peach-copy-sheen" />558                          </button>559                        </Show>560                      </div>561                    </div>562                  </div>563564                  {/* Expiry */}565                  <Show when={entry().expMonth || entry().expYear}>566                    <div class="peach-field-card">567                      <span class="peach-label">expiry</span>568                      <div class="peach-field-value-row">569                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>570                        <button571                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}572                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}573                          aria-label="Copy expiry"574                        >575                          <CopyGlyph /><span class="peach-copy-sheen" />576                        </button>577                      </div>578                    </div>579                  </Show>580581                  {/* CVC */}582                  <Show when={entry().cvc}>583                    <div class="peach-field-card">584                      <span class="peach-label">CVC</span>585                      <div class="peach-field-value-row">586                        <span class="peach-field-value">587                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>588                            <span>{entry().cvc}</span>589                          </Show>590                        </span>591                        <div class="peach-field-actions">592                          <button593                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"594                            onClick={() => setShowCardCvc(!showCardCvc())}595                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}596                          >597                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>598                              <EyeClosedGlyph />599                            </Show>600                          </button>601                          <button602                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}603                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}604                            aria-label="Copy CVC"605                          >606                            <CopyGlyph /><span class="peach-copy-sheen" />607                          </button>608                        </div>609                      </div>610                    </div>611                  </Show>612                </section>613              </Show>614615              {/* ── Login fields ─────────────────────────────────── */}616              <Show when={entry().type !== "note" && entry().type !== "card"}>617                <section class="peach-fields" style="position:relative;z-index:1;">618619                  {/* Username */}620                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>621                    <span class="peach-label">username</span>622                    <Show623                      when={editingField() === "username"}624                      fallback={625                        <div class="peach-field-value-row">626                          <span627                            class="peach-field-value peach-inline-clickable"628                            title="Click to edit"629                            onClick={() => startEdit("username", entry().username ?? "")}630                          >631                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}632                          </span>633                          <Show when={entry().username}>634                            <button635                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}636                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}637                              aria-label="Copy username"638                            >639                              <CopyGlyph /><span class="peach-copy-sheen" />640                            </button>641                          </Show>642                        </div>643                      }644                    >645                      <div646                        class="peach-inline-field-wrap"647                        onFocusOut={(event) => handleEditorFocusOut("username", event)}648                      >649                        <input650                          class="peach-inline-input"651                          value={draft()}652                          onInput={(e) => setDraft(e.currentTarget.value)}653                          onKeyDown={onEditKeyDown}654                          ref={autoFocus}655                          disabled={saving()}656                          autocomplete="off"657                        />658                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />659                      </div>660                    </Show>661                  </div>662663                  {/* Password */}664                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>665                    <span class="peach-label">password</span>666                    <Show667                      when={editingField() === "password"}668                      fallback={669                        <div class="peach-field-value-row">670                          <span671                            class="peach-password-value"672                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}673                            style="cursor:pointer;"674                            title="Click to edit password"675                            onClick={() => startEdit("password", entry().password ?? "")}676                          >677                            <Show678                              when={showPassword()}679                              fallback={<span class="peach-password-mask">...............</span>}680                            >681                              <span class="peach-password-text">{entry().password ?? ""}</span>682                            </Show>683                          </span>684                          <div class="peach-field-actions">685                            <button686                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"687                              onClick={() => setShowPassword(!showPassword())}688                              aria-label="Reveal password"689                            >690                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>691                                <EyeClosedGlyph />692                              </Show>693                            </button>694                            <button695                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}696                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}697                              aria-label="Copy password"698                            >699                              <CopyGlyph /><span class="peach-copy-sheen" />700                            </button>701                          </div>702                        </div>703                      }704                    >705                      {/* ── Edit mode: type / paste / generate ── */}706                      <div707                        class="peach-inline-field-wrap"708                        onFocusOut={(event) => handleEditorFocusOut("password", event)}709                      >710                        <div class="peach-password-edit-row">711                          <input712                            class="peach-inline-input"713                            type={showPassword() ? "text" : "password"}714                            placeholder="paste or type a password"715                            value={draft()}716                            onInput={(e) => setDraft(e.currentTarget.value)}717                            onKeyDown={(e) => {718                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }719                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }720                            }}721                            ref={autoFocus}722                            disabled={saving()}723                            autocomplete="new-password"724                          />725                          <button726                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"727                            type="button"728                            onClick={() => setShowPassword(!showPassword())}729                            aria-label={showPassword() ? "Hide" : "Show"}730                          >731                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>732                              <EyeClosedGlyph />733                            </Show>734                          </button>735                        </div>736                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />737                        <button738                          class="peach-generate-toggle-link"739                          type="button"740                          onClick={() => setShowGenerator(!showGenerator())}741                        >742                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">743                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />744                            <path d="M9.5 2.5H11V1" />745                          </svg>746                          {showGenerator() ? "hide generator" : "generate"}747                        </button>748                        <Show when={showGenerator()}>749                          <GeneratorDrawer750                            open={true}751                            compact={true}752                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}753                          />754                        </Show>755                      </div>756                    </Show>757                  </div>758759                </section>760              </Show>761762              {/* ── Notes (on login entries) ──────────────────────── */}763              <Show when={entry().type !== "note" && entry().type !== "card"}>764                <Show765                  when={entry().notes || editingField() === "notes"}766                  fallback={767                    <button768                      class="peach-add-field-link peach-entry-add-link"769                      onClick={() => startEdit("notes", "")}770                    >771                      + add notes772                    </button>773                  }774                >775                  <div style="position:relative;z-index:1;margin-top:6px;">776                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>777                    <Show778                      when={editingField() === "notes"}779                      fallback={780                        <div781                          class="peach-note-content peach-inline-clickable"782                          style="cursor:text;white-space:pre-wrap;"783                          onClick={() => startEdit("notes", entry().notes ?? "")}784                        >785                          {entry().notes}786                        </div>787                      }788                    >789                      <div790                        class="peach-inline-field-wrap"791                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}792                      >793                        <textarea794                          class="peach-inline-textarea"795                          value={draft()}796                          onInput={(e) => setDraft(e.currentTarget.value)}797                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}798                          ref={(el) => requestAnimationFrame(() => el?.focus())}799                          rows={4}800                          disabled={saving()}801                        />802                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />803                      </div>804                    </Show>805                  </div>806                </Show>807              </Show>808809              {/* ── TOTP ─────────────────────────────────────────── */}810              <Show when={entry().type === "login"}>811                <Show812                  when={totpState() || editingField() === "totp"}813                  fallback={814                    <button815                      class="peach-add-field-link peach-entry-add-link"816                      onClick={() => startEdit("totp", "")}817                    >818                      + add 2FA819                    </button>820                  }821                >822                  <section class="peach-totp-band">823                    <Show824                      when={editingField() === "totp"}825                      fallback={826                        <Show when={totpState()}>827                          {(state) => (828                            <>829                              <div830                                class="peach-totp-main"831                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}832                                onDblClick={() => startEdit("totp", entry().totp ?? "")}833                                title="Click to copy · double-click to edit"834                              >835                                <span class="peach-label">2-factor</span>836                                <div class="peach-totp-code-row">837                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>838                                    <span class="peach-totp-code-text">839                                      {state().code.slice(0, 3)} {state().code.slice(3)}840                                    </span>841                                  </p>842                                </div>843                              </div>844                              <div class="peach-totp-meta">845                                <span class="peach-label">expires</span>846                                <div class="peach-totp-countdown">{state().remaining}s</div>847                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">848                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />849                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"850                                    stroke-dasharray="106.8"851                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}852                                  />853                                </svg>854                              </div>855                            </>856                          )}857                        </Show>858                      }859                    >860                      <div861                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"862                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}863                      >864                        <span class="peach-label">2-factor</span>865                        <input866                          class="peach-inline-input"867                          value={draft()}868                          onInput={(e) => setDraft(e.currentTarget.value)}869                          onKeyDown={onEditKeyDown}870                          ref={autoFocus}871                          disabled={saving()}872                          placeholder="otpauth://... or base32 secret"873                          autocomplete="off"874                        />875                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />876                      </div>877                    </Show>878                  </section>879                </Show>880              </Show>881882              {/* ── Bottom spacer ─────────────────────────────────── */}883              <div style="height:4px;" />884            </>885          )}886        </Show>887      </div>888889    </>890  );891}892893// ── Sub-components ────────────────────────────────────────────────────894895function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {896  return (897    <div class="peach-inline-actions">898      <button899        class="peach-inline-save-btn"900        onClick={props.onSave}901        disabled={props.saving}902        aria-label="Save"903        title="Save (Enter)"904      >905        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">906          <path d="M1 4.5L4 7.5L10 1.5" />907        </svg>908      </button>909      <button910        class="peach-inline-cancel-btn"911        onClick={props.onCancel}912        disabled={props.saving}913        aria-label="Cancel"914        title="Cancel (Esc)"915      >916        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">917          <path d="M1 1L8 8M8 1L1 8" />918        </svg>919      </button>920    </div>921  );922}923924function EmptyVaultState() {925  return (926    <>927      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">928        <div class="peach-entry-name">929          <span class="peach-entry-name-text">930            <span class="peach-entry-name-char" style="--char-index:0">e</span>931            <span class="peach-entry-name-char" style="--char-index:1">m</span>932            <span class="peach-entry-name-char" style="--char-index:2">p</span>933            <span class="peach-entry-name-char" style="--char-index:3">t</span>934            <span class="peach-entry-name-char" style="--char-index:4">y</span>935          </span>936        </div>937      </div>938      <div class="peach-empty-copy">939        <span class="peach-label">vault</span>940        <p>Your vault is ready for its first entry.</p>941      </div>942    </>943  );944}945946function CopyGlyph() {947  return (948    <span class="peach-copy-icon">949      <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">950        <rect x="4" y="4" width="8" height="9" rx="1.5" />951        <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" />952      </svg>953    </span>954  );955}956957function EyeClosedGlyph() {958  return (959    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">960      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />961      <path d="M3.5 5.5L2.5 7.5" />962      <path d="M7 6.5V8.5" />963      <path d="M10.5 5.5L11.5 7.5" />964    </svg>965  );966}967968function EyeOpenGlyph() {969  return (970    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">971      <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" />972      <circle cx="7" cy="5" r="1.75" />973    </svg>974  );975}976977function ChevronGlyph(props: { open: boolean }) {978  return (979    <svg980      width="8"981      height="8"982      viewBox="0 0 8 8"983      fill="none"984      stroke="currentColor"985      stroke-width="1.3"986      stroke-linecap="round"987      stroke-linejoin="round"988      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}989    >990      <path d="M2 1L6 4L2 7" />991    </svg>992  );993}994995function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {996  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));997  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");998  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");999  const [testResult, setTestResult] = createSignal<string | null>(null);1000  const [testing, setTesting] = createSignal(false);10011002  async function save() {1003    const url = webhookUrl().trim();1004    const nextLabel = label().trim() || undefined;1005    const canary = enabled() && url1006      ? {1007          webhookUrl: url,1008          label: nextLabel,1009          lastPinged: props.entry.canary?.lastPinged,1010          pingCount: props.entry.canary?.pingCount,1011        }1012      : undefined;1013    await sendMessage({1014      type: "UPDATE_ENTRY",1015      payload: { id: props.entry.id, patch: { canary } },1016    });1017    await props.onSaved();1018  }10191020  async function testPing() {1021    const url = webhookUrl().trim();1022    if (!url) return;1023    setTesting(true);1024    setTestResult(null);1025    const res = await sendMessage({1026      type: "FIRE_CANARY_PING",1027      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1028    }) as { ok: boolean; error?: string };1029    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1030    setTesting(false);1031  }10321033  return (1034    <div class="peach-canary-section">1035      <div class="peach-field-value-row">1036        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1037        <button1038          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1039          onClick={() => { setEnabled(!enabled()); void save(); }}1040          aria-label="Toggle canary"1041        >1042          <span class="peach-toggle-knob" />1043        </button>1044      </div>1045      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1046        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1047      </p>1048      <Show when={enabled()}>1049        <div class="peach-inline-field-wrap" style="margin-top:8px;">1050          <input1051            class="peach-inline-input"1052            placeholder="https://hooks.zapier.com/..."1053            value={webhookUrl()}1054            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1055            onBlur={() => void save()}1056          />1057          <input1058            class="peach-inline-input"1059            placeholder="label (optional, e.g. AWS decoy)"1060            value={label()}1061            onInput={(e) => setLabel(e.currentTarget.value)}1062            onBlur={() => void save()}1063          />1064          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1065            <button1066              class="peach-btn peach-btn--ghost peach-btn--sm"1067              onClick={() => void testPing()}1068              disabled={testing() || !webhookUrl().trim()}1069            >1070              {testing() ? "sending..." : "test ping"}1071            </button>1072            <Show when={testResult()}>1073              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1074            </Show>1075          </div>1076          <Show when={props.entry.canary?.lastPinged}>1077            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1078              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1079              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1080            </p>1081          </Show>1082        </div>1083      </Show>1084    </div>1085  );1086}