Entry.tsx

49.3 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  // Local draft for the additional URLs list, edited only while the URL field29  // is in inline-edit mode. Saved alongside `url` when the user confirms.30  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);31  const [showCardNumber, setShowCardNumber] = createSignal(false);32  const [showCardCvc, setShowCardCvc] = createSignal(false);33  const [totpTick, setTotpTick] = createSignal(0);34  const [confirmDelete, setConfirmDelete] = createSignal(false);35  const [totpHot, setTotpHot] = createSignal(false);36  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);37  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);38  let lastSavedNote: string | null = null;3940  function noteExec(cmd: string, val?: string) {41    document.execCommand(cmd, false, val);42    noteEditorEl()?.focus();43  }4445  async function saveNoteInline(html: string) {46    const entry = props.entry;47    if (!entry) return;48    const currentStored = entry.notes ?? "";49    if (html === currentStored) return;50    if (lastSavedNote !== null && html === lastSavedNote) return;51    lastSavedNote = html;52    setSaving(true);53    try {54      await sendMessage({55        type: "UPDATE_ENTRY",56        payload: { id: entry.id, patch: { notes: html } },57      });58      triggerRing();59      await props.onFieldSaved();60    } catch {61      lastSavedNote = null;62    } finally {63      setSaving(false);64    }65  }6667  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);68  onCleanup(() => window.clearInterval(interval));6970  const displayDomain = createMemo(() =>71    props.entry?.url ? getDisplayDomain(props.entry.url) : ""72  );7374  createEffect(() => {75    const domain = displayDomain();76    setFaviconSrc(domain ? faviconUrl(domain) : null);77  });7879  const totpState = createMemo(() => {80    void totpTick();81    if (!props.entry?.totp) return null;82    return generateTOTP(props.entry.totp);83  });8485  let prevTotpCode: string | undefined;86  createEffect(() => {87    const state = totpState();88    if (!state) { prevTotpCode = undefined; return; }89    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {90      setTotpHot(true);91      window.setTimeout(() => setTotpHot(false), 3000);92    }93    prevTotpCode = state.code;94  });9596  const totpProgress = createMemo(() => {97    const state = totpState();98    if (!state) return 0;99    return ((state.remaining - 1) / 29) * 100;100  });101102  createEffect(() => {103    const shell = document.getElementById("hd-shell");104    if (!shell) return;105106    const state = totpState();107    if (!state) {108      delete shell.dataset.totpActive;109      shell.style.removeProperty("--peach-totp-progress");110      return;111    }112113    shell.dataset.totpActive = "true";114    shell.style.setProperty(115      "--peach-totp-progress",116      `${Math.max(0, totpProgress())}%`117    );118119    onCleanup(() => {120      delete shell.dataset.totpActive;121      shell.style.removeProperty("--peach-totp-progress");122    });123  });124125  const titleSizeClass = createMemo(() => {126    const length = props.entry?.title.length ?? 0;127    if (length >= 28) return "peach-entry-name--xxl";128    if (length >= 20) return "peach-entry-name--xl";129    if (length >= 14) return "peach-entry-name--lg";130    return "";131  });132  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);133134  // ── Inline editing ────────────────────────────────────────────────135136  function startEdit(field: EditableField, value: string) {137    setEditingField(field);138    setDraft(value);139    setShowGenerator(false);140    setConfirmDelete(false);141    if (field === "url") {142      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);143    }144  }145146  async function saveField() {147    const field = editingField();148    const entry = props.entry;149    if (!field || !entry) return;150    // Don't trim passwords — spaces may be intentional151    let value = field === "password" ? draft() : draft().trim();152    if (field === "totp" && value.startsWith("otpauth://")) {153      const parsed = parseOtpauthURI(value);154      value = parsed?.secret ?? value;155    }156    if (field === "password" && entry.type !== "note" && !value) {157      return;158    }159    setSaving(true);160    try {161      const patch: Partial<VaultEntry> = { [field]: value };162      if (field === "url") {163        // Sanitization layer in vault.ts trims, dedupes, and drops empties.164        patch.additionalUrls = additionalUrlsDraft();165      }166      await sendMessage({167        type: "UPDATE_ENTRY",168        payload: { id: entry.id, patch },169      });170      triggerRing();171      setEditingField(null);172      setShowGenerator(false);173      await props.onFieldSaved();174    } catch {175      // silent — leave editing open176    } finally {177      setSaving(false);178    }179  }180181  function cancelEdit() {182    setEditingField(null);183    setDraft("");184    setShowGenerator(false);185  }186187  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {188    switch (field) {189      case "title":190        return entry.title;191      case "url":192        return entry.url ?? "";193      case "username":194        return entry.username ?? "";195      case "password":196        return entry.password ?? "";197      case "notes":198        return entry.notes ?? "";199      case "totp":200        return entry.totp ?? "";201    }202  }203204  function handleEditorFocusOut(205    field: Exclude<EditableField, null>,206    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }207  ) {208    const nextTarget = event.relatedTarget;209    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;210211    const entry = props.entry;212    if (!entry) return;213214    const currentValue = fieldValue(entry, field);215    const nextValue = field === "password" ? draft() : draft().trim();216    if (nextValue === currentValue) {217      cancelEdit();218    }219  }220221  async function savePassword(password: string) {222    const entry = props.entry;223    if (!entry) return;224    setSaving(true);225    try {226      await sendMessage({227        type: "UPDATE_ENTRY",228        payload: { id: entry.id, patch: { password } },229      });230      triggerRing();231      setShowGenerator(false);232      await props.onFieldSaved();233    } catch {234      // silent235    } finally {236      setSaving(false);237    }238  }239240  // ── Copy ─────────────────────────────────────────────────────────241242  async function copyField(text: string, label: string, button?: HTMLButtonElement) {243    const ok = await popupCopy(text, label);244    if (ok) {245      const entry = props.entry;246      if (entry?.canary?.webhookUrl) {247        const trigger =248          label === "password"249            ? "password_copied"250            : label === "username"251              ? "username_copied"252              : undefined;253        if (trigger) {254          void sendMessage({255            type: "FIRE_CANARY_PING",256            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },257          });258        }259      }260      triggerRing();261      triggerPipPulse();262      setCopied(null);263      requestAnimationFrame(() => setCopied(label));264      if (button) {265        button.classList.remove("is-copied", "is-retreat");266        void button.offsetWidth;267        const drain = document.createElement("span");268        drain.className = "peach-copy-drain";269        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";270        drain.appendChild(bar);271        button.appendChild(drain);272        window.setTimeout(() => {273          button.classList.remove("is-copied");274          button.classList.add("is-retreat");275          drain.remove();276          window.setTimeout(() => button.classList.remove("is-retreat"), 160);277        }, 1800);278      }279      window.setTimeout(() => setCopied(null), 1800);280    }281  }282283  // ── Keyboard shortcuts ────────────────────────────────────────────284285  createEffect(() => {286    if (!props.entry) return;287    const handler = (e: KeyboardEvent) => {288      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;289      if (inInput) return;290      switch (e.key) {291        case "c": e.preventDefault();292          if (props.entry?.password) void copyField(props.entry.password, "password"); break;293        case "u": e.preventDefault();294          if (props.entry?.username) void copyField(props.entry.username, "username"); break;295        case "Escape": e.preventDefault(); props.onEscape?.(); break;296      }297    };298    window.addEventListener("keydown", handler);299    onCleanup(() => window.removeEventListener("keydown", handler));300  });301302  // ── Input ref focus helper ────────────────────────────────────────303304  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {305    requestAnimationFrame(() => { el.focus(); el.select?.(); });306  }307308  function onEditKeyDown(e: KeyboardEvent) {309    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {310      e.preventDefault();311      void saveField();312    }313    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }314  }315316  return (317    <>318      <div319        class="peach-ghost"320        aria-hidden="true"321        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;"322      >323        {props.entry?.title || "empty"}324      </div>325326      <div class="peach-stage" style="position:relative;">327        <Show when={props.entry} fallback={<EmptyVaultState />}>328          {(entry) => (329            <>330              {/* ── Breach strip ─────────────────────────────────── */}331              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>332                <div class="peach-breach-badge">333                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">334                    <path d="M6 1L11 10H1L6 1z" />335                    <line x1="6" y1="5" x2="6" y2="7.5" />336                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />337                  </svg>338                  <span style="flex:1">found in a known data breach</span>339                  <button340                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"341                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}342                  >343                    Change password344                  </button>345                </div>346              </Show>347348              {/* ── Title + trash ─────────────────────────────────── */}349              <div class="peach-entry-header">350                <div class="peach-entry-header-name">351                  <Show352                    when={editingField() === "title"}353                    fallback={354                      <button355                        class="peach-entry-name-button"356                        title="Click to edit title"357                        onClick={() => startEdit("title", entry().title)}358                      >359                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>360                          <span class="peach-entry-name-text">361                            <Show362                              when={useAnimatedTitle()}363                              fallback={entry().title}364                            >365                              <For each={entry().title.split("")}>366                                {(ch, i) => (367                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>368                                    {ch === " " ? "\u00A0" : ch}369                                  </span>370                                )}371                              </For>372                            </Show>373                          </span>374                        </h1>375                      </button>376                    }377                  >378                    <div379                      class="peach-inline-title-wrap"380                      onFocusOut={(event) => handleEditorFocusOut("title", event)}381                    >382                      <input383                        class="peach-inline-title-input"384                        value={draft()}385                        onInput={(e) => setDraft(e.currentTarget.value)}386                        onKeyDown={onEditKeyDown}387                        ref={autoFocus}388                        disabled={saving()}389                      />390                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />391                    </div>392                  </Show>393                </div>394395                {/* Subtle trash icon — hidden while editing title */}396                <Show when={editingField() !== "title"}>397                  <Show398                    when={confirmDelete()}399                    fallback={400                      <button401                        class="peach-entry-trash-btn"402                        onClick={() => setConfirmDelete(true)}403                        aria-label="Move to trash"404                        title="Delete entry"405                      >406                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">407                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />408                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />409                        </svg>410                      </button>411                    }412                  >413                    <div class="peach-entry-trash-confirm">414                      <span class="peach-entry-trash-confirm-label">trash?</span>415                      <button416                        class="peach-btn peach-btn--sm peach-btn--danger"417                        style="font-size:10px;padding:2px 8px;min-height:22px;"418                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}419                      >420                        yes421                      </button>422                      <button423                        class="peach-btn peach-btn--ghost peach-btn--sm"424                        style="font-size:10px;padding:2px 8px;min-height:22px;"425                        onClick={() => setConfirmDelete(false)}426                      >427                        no428                      </button>429                    </div>430                  </Show>431                </Show>432              </div>433434              {/* ── Domain / URL ─────────────────────────────────── */}435              <Show when={entry().type !== "note" && entry().type !== "card"}>436                <div class="peach-domain" style="position:relative;z-index:1;">437                  <Show438                    when={editingField() === "url"}439                    fallback={440                      <div441                        class="peach-domain-row peach-inline-clickable"442                        title="Click to edit URL"443                        onClick={() => startEdit("url", entry().url ?? "")}444                      >445                        <Show when={faviconSrc()}>446                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />447                        </Show>448                        <span class="peach-domain-value">449                          {displayDomain() || (entry().url ? entry().url : "add a website url")}450                        </span>451                        <span class="peach-inline-edit-hint">edit</span>452                      </div>453                    }454                  >455                    <div>456                      <div457                        class="peach-inline-field-wrap"458                        onFocusOut={(event) => handleEditorFocusOut("url", event)}459                      >460                        <input461                          class="peach-inline-input"462                          type="url"463                          placeholder="https://"464                          value={draft()}465                          onInput={(e) => setDraft(e.currentTarget.value)}466                          onKeyDown={onEditKeyDown}467                          ref={autoFocus}468                          disabled={saving()}469                        />470                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />471                      </div>472                      <div class="peach-additional-urls">473                        <Show when={additionalUrlsDraft().length > 0}>474                          <span class="peach-label peach-additional-urls-label">also matches</span>475                        </Show>476                        <For each={additionalUrlsDraft()}>477                          {(value, i) => (478                            <div class="peach-additional-url-row">479                              <input480                                class="peach-inline-input"481                                type="url"482                                placeholder="https://"483                                value={value}484                                onInput={(e) => setAdditionalUrlsDraft((arr) => {485                                  const next = [...arr];486                                  next[i()] = e.currentTarget.value;487                                  return next;488                                })}489                                disabled={saving()}490                              />491                              <button492                                class="peach-additional-url-remove"493                                type="button"494                                aria-label="Remove URL"495                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}496                                disabled={saving()}497                              >×</button>498                            </div>499                          )}500                        </For>501                        <button502                          class="peach-add-field-link peach-additional-urls-add"503                          type="button"504                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}505                          disabled={saving()}506                        >507                          + add another url508                        </button>509                      </div>510                    </div>511                  </Show>512                </div>513              </Show>514515              {/* ── Tags ─────────────────────────────────────────── */}516              <Show when={entry().tags && entry().tags!.length > 0}>517                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">518                  <For each={entry().tags!}>519                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}520                  </For>521                </div>522              </Show>523524              {/* ── Note toolbar + inline editor ─────────────────── */}525              <Show when={entry().type === "note"}>526                <div class="peach-entry-note-toolbar-row">527                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />528                </div>529                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">530                  <NoteEditorInline531                    entryKey={entry().id}532                    initialContent={entry().notes ?? ""}533                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}534                    onBlur={saveNoteInline}535                    autoFocus={false}536                  />537                </section>538              </Show>539540              {/* ── Card detail view ─────────────────────────────── */}541              <Show when={entry().type === "card"}>542                <section class="peach-fields" style="position:relative;z-index:1;">543                  {/* Card subtitle: •••• last4 */}544                  <Show when={entry().last4}>545                    <div class="peach-field-card" style="padding-bottom:4px;">546                      <span class="peach-label">547                        <Show when={entry().network && entry().network !== "unknown"}>548                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>549                        </Show>550                        {entry().cardNickname || `•••• ${entry().last4}`}551                      </span>552                    </div>553                  </Show>554555                  {/* Cardholder name */}556                  <div class="peach-field-card">557                    <span class="peach-label">cardholder name</span>558                    <div class="peach-field-value-row">559                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>560                      <Show when={entry().cardholderName}>561                        <button562                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}563                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}564                          aria-label="Copy cardholder name"565                        >566                          <CopyGlyph /><span class="peach-copy-sheen" />567                        </button>568                      </Show>569                    </div>570                  </div>571572                  {/* Card number */}573                  <div class="peach-field-card">574                    <span class="peach-label">card number</span>575                    <div class="peach-field-value-row">576                      <span class={`peach-field-value peach-card-number`}>577                        <Show when={showCardNumber()} fallback={578                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>579                        }>580                          <span>{entry().cardNumber ?? ""}</span>581                        </Show>582                      </span>583                      <div class="peach-field-actions">584                        <button585                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"586                          onClick={() => setShowCardNumber(!showCardNumber())}587                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}588                        >589                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>590                            <EyeClosedGlyph />591                          </Show>592                        </button>593                        <Show when={entry().cardNumber}>594                          <button595                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}596                            onClick={(e) => {597                              const cardNum = entry().cardNumber!;598                              if (entry().canary?.webhookUrl) {599                                void sendMessage({600                                  type: "FIRE_CANARY_PING",601                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },602                                });603                              }604                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);605                            }}606                            aria-label="Copy card number"607                          >608                            <CopyGlyph /><span class="peach-copy-sheen" />609                          </button>610                        </Show>611                      </div>612                    </div>613                  </div>614615                  {/* Expiry */}616                  <Show when={entry().expMonth || entry().expYear}>617                    <div class="peach-field-card">618                      <span class="peach-label">expiry</span>619                      <div class="peach-field-value-row">620                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>621                        <button622                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}623                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}624                          aria-label="Copy expiry"625                        >626                          <CopyGlyph /><span class="peach-copy-sheen" />627                        </button>628                      </div>629                    </div>630                  </Show>631632                  {/* CVC */}633                  <Show when={entry().cvc}>634                    <div class="peach-field-card">635                      <span class="peach-label">CVC</span>636                      <div class="peach-field-value-row">637                        <span class="peach-field-value">638                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>639                            <span>{entry().cvc}</span>640                          </Show>641                        </span>642                        <div class="peach-field-actions">643                          <button644                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"645                            onClick={() => setShowCardCvc(!showCardCvc())}646                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}647                          >648                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>649                              <EyeClosedGlyph />650                            </Show>651                          </button>652                          <button653                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}654                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}655                            aria-label="Copy CVC"656                          >657                            <CopyGlyph /><span class="peach-copy-sheen" />658                          </button>659                        </div>660                      </div>661                    </div>662                  </Show>663                </section>664              </Show>665666              {/* ── Login fields ─────────────────────────────────── */}667              <Show when={entry().type !== "note" && entry().type !== "card"}>668                <section class="peach-fields" style="position:relative;z-index:1;">669670                  {/* Username */}671                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>672                    <span class="peach-label">username</span>673                    <Show674                      when={editingField() === "username"}675                      fallback={676                        <div class="peach-field-value-row">677                          <span678                            class="peach-field-value peach-inline-clickable"679                            title="Click to edit"680                            onClick={() => startEdit("username", entry().username ?? "")}681                          >682                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}683                          </span>684                          <Show when={entry().username}>685                            <button686                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}687                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}688                              aria-label="Copy username"689                            >690                              <CopyGlyph /><span class="peach-copy-sheen" />691                            </button>692                          </Show>693                        </div>694                      }695                    >696                      <div697                        class="peach-inline-field-wrap"698                        onFocusOut={(event) => handleEditorFocusOut("username", event)}699                      >700                        <input701                          class="peach-inline-input"702                          value={draft()}703                          onInput={(e) => setDraft(e.currentTarget.value)}704                          onKeyDown={onEditKeyDown}705                          ref={autoFocus}706                          disabled={saving()}707                          autocomplete="off"708                        />709                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />710                      </div>711                    </Show>712                  </div>713714                  {/* Password */}715                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>716                    <span class="peach-label">password</span>717                    <Show718                      when={editingField() === "password"}719                      fallback={720                        <div class="peach-field-value-row">721                          <span722                            class="peach-password-value"723                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}724                            style="cursor:pointer;"725                            title="Click to edit password"726                            onClick={() => startEdit("password", entry().password ?? "")}727                          >728                            <Show729                              when={showPassword()}730                              fallback={<span class="peach-password-mask">...............</span>}731                            >732                              <span class="peach-password-text">{entry().password ?? ""}</span>733                            </Show>734                          </span>735                          <div class="peach-field-actions">736                            <button737                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"738                              onClick={() => setShowPassword(!showPassword())}739                              aria-label="Reveal password"740                            >741                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>742                                <EyeClosedGlyph />743                              </Show>744                            </button>745                            <button746                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}747                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}748                              aria-label="Copy password"749                            >750                              <CopyGlyph /><span class="peach-copy-sheen" />751                            </button>752                          </div>753                        </div>754                      }755                    >756                      {/* ── Edit mode: type / paste / generate ── */}757                      <div758                        class="peach-inline-field-wrap"759                        onFocusOut={(event) => handleEditorFocusOut("password", event)}760                      >761                        <div class="peach-password-edit-row">762                          <input763                            class="peach-inline-input"764                            type={showPassword() ? "text" : "password"}765                            placeholder="paste or type a password"766                            value={draft()}767                            onInput={(e) => setDraft(e.currentTarget.value)}768                            onKeyDown={(e) => {769                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }770                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }771                            }}772                            ref={autoFocus}773                            disabled={saving()}774                            autocomplete="new-password"775                          />776                          <button777                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"778                            type="button"779                            onClick={() => setShowPassword(!showPassword())}780                            aria-label={showPassword() ? "Hide" : "Show"}781                          >782                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>783                              <EyeClosedGlyph />784                            </Show>785                          </button>786                        </div>787                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />788                        <button789                          class="peach-generate-toggle-link"790                          type="button"791                          onClick={() => setShowGenerator(!showGenerator())}792                        >793                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">794                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />795                            <path d="M9.5 2.5H11V1" />796                          </svg>797                          {showGenerator() ? "hide generator" : "generate"}798                        </button>799                        <Show when={showGenerator()}>800                          <GeneratorDrawer801                            open={true}802                            compact={true}803                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}804                          />805                        </Show>806                      </div>807                    </Show>808                  </div>809810                </section>811              </Show>812813              {/* ── Notes (on login entries) ──────────────────────── */}814              <Show when={entry().type !== "note" && entry().type !== "card"}>815                <Show816                  when={entry().notes || editingField() === "notes"}817                  fallback={818                    <button819                      class="peach-add-field-link peach-entry-add-link"820                      onClick={() => startEdit("notes", "")}821                    >822                      + add notes823                    </button>824                  }825                >826                  <div style="position:relative;z-index:1;margin-top:6px;">827                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>828                    <Show829                      when={editingField() === "notes"}830                      fallback={831                        <div832                          class="peach-note-content peach-inline-clickable"833                          style="cursor:text;white-space:pre-wrap;"834                          onClick={() => startEdit("notes", entry().notes ?? "")}835                        >836                          {entry().notes}837                        </div>838                      }839                    >840                      <div841                        class="peach-inline-field-wrap"842                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}843                      >844                        <textarea845                          class="peach-inline-textarea"846                          value={draft()}847                          onInput={(e) => setDraft(e.currentTarget.value)}848                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}849                          ref={(el) => requestAnimationFrame(() => el?.focus())}850                          rows={4}851                          disabled={saving()}852                        />853                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />854                      </div>855                    </Show>856                  </div>857                </Show>858              </Show>859860              {/* ── TOTP ─────────────────────────────────────────── */}861              <Show when={entry().type === "login"}>862                <Show863                  when={totpState() || editingField() === "totp"}864                  fallback={865                    <button866                      class="peach-add-field-link peach-entry-add-link"867                      onClick={() => startEdit("totp", "")}868                    >869                      + add 2FA870                    </button>871                  }872                >873                  <section class="peach-totp-band">874                    <Show875                      when={editingField() === "totp"}876                      fallback={877                        <Show when={totpState()}>878                          {(state) => (879                            <>880                              <div881                                class="peach-totp-main"882                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}883                                onDblClick={() => startEdit("totp", entry().totp ?? "")}884                                title="Click to copy · double-click to edit"885                              >886                                <span class="peach-label">2-factor</span>887                                <div class="peach-totp-code-row">888                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>889                                    <span class="peach-totp-code-text">890                                      {state().code.slice(0, 3)} {state().code.slice(3)}891                                    </span>892                                  </p>893                                </div>894                              </div>895                              <div class="peach-totp-meta">896                                <span class="peach-label">expires</span>897                                <div class="peach-totp-countdown">{state().remaining}s</div>898                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">899                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />900                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"901                                    stroke-dasharray="106.8"902                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}903                                  />904                                </svg>905                              </div>906                            </>907                          )}908                        </Show>909                      }910                    >911                      <div912                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"913                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}914                      >915                        <span class="peach-label">2-factor</span>916                        <input917                          class="peach-inline-input"918                          value={draft()}919                          onInput={(e) => setDraft(e.currentTarget.value)}920                          onKeyDown={onEditKeyDown}921                          ref={autoFocus}922                          disabled={saving()}923                          placeholder="otpauth://... or base32 secret"924                          autocomplete="off"925                        />926                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />927                      </div>928                    </Show>929                  </section>930                </Show>931              </Show>932933              {/* ── Bottom spacer ─────────────────────────────────── */}934              <div style="height:4px;" />935            </>936          )}937        </Show>938      </div>939940    </>941  );942}943944// ── Sub-components ────────────────────────────────────────────────────945946function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {947  return (948    <div class="peach-inline-actions">949      <button950        class="peach-inline-save-btn"951        onClick={props.onSave}952        disabled={props.saving}953        aria-label="Save"954        title="Save (Enter)"955      >956        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">957          <path d="M1 4.5L4 7.5L10 1.5" />958        </svg>959      </button>960      <button961        class="peach-inline-cancel-btn"962        onClick={props.onCancel}963        disabled={props.saving}964        aria-label="Cancel"965        title="Cancel (Esc)"966      >967        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">968          <path d="M1 1L8 8M8 1L1 8" />969        </svg>970      </button>971    </div>972  );973}974975function EmptyVaultState() {976  return (977    <>978      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">979        <div class="peach-entry-name">980          <span class="peach-entry-name-text">981            <span class="peach-entry-name-char" style="--char-index:0">e</span>982            <span class="peach-entry-name-char" style="--char-index:1">m</span>983            <span class="peach-entry-name-char" style="--char-index:2">p</span>984            <span class="peach-entry-name-char" style="--char-index:3">t</span>985            <span class="peach-entry-name-char" style="--char-index:4">y</span>986          </span>987        </div>988      </div>989      <div class="peach-empty-copy">990        <span class="peach-label">vault</span>991        <p>Your vault is ready for its first entry.</p>992      </div>993    </>994  );995}996997function CopyGlyph() {998  return (999    <span class="peach-copy-icon">1000      <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">1001        <rect x="4" y="4" width="8" height="9" rx="1.5" />1002        <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" />1003      </svg>1004    </span>1005  );1006}10071008function EyeClosedGlyph() {1009  return (1010    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1011      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1012      <path d="M3.5 5.5L2.5 7.5" />1013      <path d="M7 6.5V8.5" />1014      <path d="M10.5 5.5L11.5 7.5" />1015    </svg>1016  );1017}10181019function EyeOpenGlyph() {1020  return (1021    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1022      <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" />1023      <circle cx="7" cy="5" r="1.75" />1024    </svg>1025  );1026}10271028function ChevronGlyph(props: { open: boolean }) {1029  return (1030    <svg1031      width="8"1032      height="8"1033      viewBox="0 0 8 8"1034      fill="none"1035      stroke="currentColor"1036      stroke-width="1.3"1037      stroke-linecap="round"1038      stroke-linejoin="round"1039      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1040    >1041      <path d="M2 1L6 4L2 7" />1042    </svg>1043  );1044}10451046function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1047  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1048  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1049  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1050  const [testResult, setTestResult] = createSignal<string | null>(null);1051  const [testing, setTesting] = createSignal(false);10521053  async function save() {1054    const url = webhookUrl().trim();1055    const nextLabel = label().trim() || undefined;1056    const canary = enabled() && url1057      ? {1058          webhookUrl: url,1059          label: nextLabel,1060          lastPinged: props.entry.canary?.lastPinged,1061          pingCount: props.entry.canary?.pingCount,1062        }1063      : undefined;1064    await sendMessage({1065      type: "UPDATE_ENTRY",1066      payload: { id: props.entry.id, patch: { canary } },1067    });1068    await props.onSaved();1069  }10701071  async function testPing() {1072    const url = webhookUrl().trim();1073    if (!url) return;1074    setTesting(true);1075    setTestResult(null);1076    const res = await sendMessage({1077      type: "FIRE_CANARY_PING",1078      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1079    }) as { ok: boolean; error?: string };1080    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1081    setTesting(false);1082  }10831084  return (1085    <div class="peach-canary-section">1086      <div class="peach-field-value-row">1087        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1088        <button1089          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1090          onClick={() => { setEnabled(!enabled()); void save(); }}1091          aria-label="Toggle canary"1092        >1093          <span class="peach-toggle-knob" />1094        </button>1095      </div>1096      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1097        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1098      </p>1099      <Show when={enabled()}>1100        <div class="peach-inline-field-wrap" style="margin-top:8px;">1101          <input1102            class="peach-inline-input"1103            placeholder="https://hooks.zapier.com/..."1104            value={webhookUrl()}1105            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1106            onBlur={() => void save()}1107          />1108          <input1109            class="peach-inline-input"1110            placeholder="label (optional, e.g. AWS decoy)"1111            value={label()}1112            onInput={(e) => setLabel(e.currentTarget.value)}1113            onBlur={() => void save()}1114          />1115          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1116            <button1117              class="peach-btn peach-btn--ghost peach-btn--sm"1118              onClick={() => void testPing()}1119              disabled={testing() || !webhookUrl().trim()}1120            >1121              {testing() ? "sending..." : "test ping"}1122            </button>1123            <Show when={testResult()}>1124              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1125            </Show>1126          </div>1127          <Show when={props.entry.canary?.lastPinged}>1128            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1129              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1130              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1131            </p>1132          </Show>1133        </div>1134      </Show>1135    </div>1136  );1137}