Entry.tsx

53.8 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, 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 [editingTags, setEditingTags] = createSignal(false);32  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);33  const [tagInput, setTagInput] = createSignal("");34  const [showCardNumber, setShowCardNumber] = createSignal(false);35  const [showCardCvc, setShowCardCvc] = createSignal(false);36  const [totpTick, setTotpTick] = createSignal(0);37  const [confirmDelete, setConfirmDelete] = createSignal(false);38  const [totpHot, setTotpHot] = createSignal(false);39  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);40  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);41  let lastSavedNote: string | null = null;4243  function noteExec(cmd: string, val?: string) {44    document.execCommand(cmd, false, val);45    noteEditorEl()?.focus();46  }4748  async function saveNoteInline(html: string) {49    const entry = props.entry;50    if (!entry) return;51    const currentStored = entry.notes ?? "";52    if (html === currentStored) return;53    if (lastSavedNote !== null && html === lastSavedNote) return;54    lastSavedNote = html;55    setSaving(true);56    try {57      await sendMessage({58        type: "UPDATE_ENTRY",59        payload: { id: entry.id, patch: { notes: html } },60      });61      triggerRing();62      await props.onFieldSaved();63    } catch {64      lastSavedNote = null;65    } finally {66      setSaving(false);67    }68  }6970  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);71  onCleanup(() => window.clearInterval(interval));7273  const displayDomain = createMemo(() =>74    props.entry?.url ? getDisplayDomain(props.entry.url) : ""75  );7677  createEffect(() => {78    const domain = displayDomain();79    setFaviconSrc(domain ? faviconUrl(domain) : null);80  });8182  const totpState = createMemo(() => {83    void totpTick();84    if (!props.entry?.totp) return null;85    return generateTOTP(props.entry.totp);86  });8788  let prevTotpCode: string | undefined;89  createEffect(() => {90    const state = totpState();91    if (!state) { prevTotpCode = undefined; return; }92    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {93      setTotpHot(true);94      window.setTimeout(() => setTotpHot(false), 3000);95    }96    prevTotpCode = state.code;97  });9899  const totpProgress = createMemo(() => {100    const state = totpState();101    if (!state) return 0;102    return ((state.remaining - 1) / 29) * 100;103  });104105  createEffect(() => {106    const shell = document.getElementById("hd-shell");107    if (!shell) return;108109    const state = totpState();110    if (!state) {111      delete shell.dataset.totpActive;112      shell.style.removeProperty("--peach-totp-progress");113      return;114    }115116    shell.dataset.totpActive = "true";117    shell.style.setProperty(118      "--peach-totp-progress",119      `${Math.max(0, totpProgress())}%`120    );121122    onCleanup(() => {123      delete shell.dataset.totpActive;124      shell.style.removeProperty("--peach-totp-progress");125    });126  });127128  const titleSizeClass = createMemo(() => {129    const length = props.entry?.title.length ?? 0;130    if (length >= 28) return "peach-entry-name--xxl";131    if (length >= 20) return "peach-entry-name--xl";132    if (length >= 14) return "peach-entry-name--lg";133    return "";134  });135  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);136137  // ── Inline editing ────────────────────────────────────────────────138139  function startEdit(field: EditableField, value: string) {140    setEditingField(field);141    setDraft(value);142    setShowGenerator(false);143    setConfirmDelete(false);144    if (field === "url") {145      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);146    }147  }148149  async function saveField() {150    const field = editingField();151    const entry = props.entry;152    if (!field || !entry) return;153    // Don't trim passwords — spaces may be intentional154    let value = field === "password" ? draft() : draft().trim();155    if (field === "totp" && value.startsWith("otpauth://")) {156      const parsed = parseOtpauthURI(value);157      value = parsed?.secret ?? value;158    }159    if (field === "password" && entry.type !== "note" && !value) {160      return;161    }162    setSaving(true);163    try {164      const patch: Partial<VaultEntry> = { [field]: value };165      if (field === "url") {166        // Sanitization layer in vault.ts trims, dedupes, and drops empties.167        patch.additionalUrls = additionalUrlsDraft();168      }169      await sendMessage({170        type: "UPDATE_ENTRY",171        payload: { id: entry.id, patch },172      });173      triggerRing();174      setEditingField(null);175      setShowGenerator(false);176      await props.onFieldSaved();177    } catch {178      // silent — leave editing open179    } finally {180      setSaving(false);181    }182  }183184  function startEditTags() {185    setTagsDraft([...(props.entry?.tags ?? [])]);186    setTagInput("");187    setEditingTags(true);188  }189190  function addTag(value: string) {191    const n = value.toLowerCase().trim();192    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;193    setTagsDraft([...tagsDraft(), n]);194    setTagInput("");195  }196197  async function saveTags() {198    if (tagInput()) addTag(tagInput());199    const entry = props.entry;200    if (!entry) return;201    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;202    setEditingTags(false);203    setSaving(true);204    try {205      await sendMessage({206        type: "UPDATE_ENTRY",207        payload: { id: entry.id, patch: { tags: next } },208      });209      triggerRing();210      await props.onFieldSaved();211    } catch {212      // silent213    } finally {214      setSaving(false);215    }216  }217218  function cancelEdit() {219    setEditingField(null);220    setDraft("");221    setShowGenerator(false);222  }223224  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {225    switch (field) {226      case "title":227        return entry.title;228      case "url":229        return entry.url ?? "";230      case "username":231        return entry.username ?? "";232      case "password":233        return entry.password ?? "";234      case "notes":235        return entry.notes ?? "";236      case "totp":237        return entry.totp ?? "";238    }239  }240241  function handleEditorFocusOut(242    field: Exclude<EditableField, null>,243    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }244  ) {245    const nextTarget = event.relatedTarget;246    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;247248    const entry = props.entry;249    if (!entry) return;250251    const currentValue = fieldValue(entry, field);252    const nextValue = field === "password" ? draft() : draft().trim();253    if (nextValue === currentValue) {254      cancelEdit();255    }256  }257258  async function savePassword(password: string) {259    const entry = props.entry;260    if (!entry) return;261    setSaving(true);262    try {263      await sendMessage({264        type: "UPDATE_ENTRY",265        payload: { id: entry.id, patch: { password } },266      });267      triggerRing();268      setShowGenerator(false);269      await props.onFieldSaved();270    } catch {271      // silent272    } finally {273      setSaving(false);274    }275  }276277  // ── Copy ─────────────────────────────────────────────────────────278279  async function copyField(text: string, label: string, button?: HTMLButtonElement) {280    const ok = await popupCopy(text, label);281    if (ok) {282      const entry = props.entry;283      if (entry?.canary?.webhookUrl) {284        const trigger =285          label === "password"286            ? "password_copied"287            : label === "username"288              ? "username_copied"289              : undefined;290        if (trigger) {291          void sendMessage({292            type: "FIRE_CANARY_PING",293            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },294          });295        }296      }297      triggerRing();298      triggerPipPulse();299      setCopied(null);300      requestAnimationFrame(() => setCopied(label));301      if (button) {302        button.classList.remove("is-copied", "is-retreat");303        void button.offsetWidth;304        const drain = document.createElement("span");305        drain.className = "peach-copy-drain";306        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";307        drain.appendChild(bar);308        button.appendChild(drain);309        window.setTimeout(() => {310          button.classList.remove("is-copied");311          button.classList.add("is-retreat");312          drain.remove();313          window.setTimeout(() => button.classList.remove("is-retreat"), 160);314        }, 1800);315      }316      window.setTimeout(() => setCopied(null), 1800);317    }318  }319320  // ── Keyboard shortcuts ────────────────────────────────────────────321322  createEffect(() => {323    if (!props.entry) return;324    const handler = (e: KeyboardEvent) => {325      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;326      if (inInput) return;327      switch (e.key) {328        case "c": e.preventDefault();329          if (props.entry?.password) void copyField(props.entry.password, "password"); break;330        case "u": e.preventDefault();331          if (props.entry?.username) void copyField(props.entry.username, "username"); break;332        case "Escape": e.preventDefault(); props.onEscape?.(); break;333      }334    };335    window.addEventListener("keydown", handler);336    onCleanup(() => window.removeEventListener("keydown", handler));337  });338339  // ── Input ref focus helper ────────────────────────────────────────340341  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {342    requestAnimationFrame(() => { el.focus(); el.select?.(); });343  }344345  function onEditKeyDown(e: KeyboardEvent) {346    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {347      e.preventDefault();348      void saveField();349    }350    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }351  }352353  return (354    <>355      <div356        class="peach-ghost"357        aria-hidden="true"358        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;"359      >360        {props.entry?.title || "empty"}361      </div>362363      <div class="peach-stage" style="position:relative;">364        <Show when={props.entry} fallback={<EmptyVaultState />}>365          {(entry) => (366            <>367              {/* ── Breach strip ─────────────────────────────────── */}368              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>369                <div class="peach-breach-badge">370                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">371                    <path d="M6 1L11 10H1L6 1z" />372                    <line x1="6" y1="5" x2="6" y2="7.5" />373                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />374                  </svg>375                  <span style="flex:1">found in a known data breach</span>376                  <button377                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"378                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}379                  >380                    Change password381                  </button>382                </div>383              </Show>384385              {/* ── Title + trash ─────────────────────────────────── */}386              <div class="peach-entry-header">387                <div class="peach-entry-header-name">388                  <Show389                    when={editingField() === "title"}390                    fallback={391                      <button392                        class="peach-entry-name-button"393                        title="Click to edit title"394                        onClick={() => startEdit("title", entry().title)}395                      >396                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>397                          <span class="peach-entry-name-text">398                            <Show399                              when={useAnimatedTitle()}400                              fallback={entry().title}401                            >402                              <For each={entry().title.split("")}>403                                {(ch, i) => (404                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>405                                    {ch === " " ? "\u00A0" : ch}406                                  </span>407                                )}408                              </For>409                            </Show>410                          </span>411                        </h1>412                      </button>413                    }414                  >415                    <div416                      class="peach-inline-title-wrap"417                      onFocusOut={(event) => handleEditorFocusOut("title", event)}418                    >419                      <input420                        class="peach-inline-title-input"421                        value={draft()}422                        onInput={(e) => setDraft(e.currentTarget.value)}423                        onKeyDown={onEditKeyDown}424                        ref={autoFocus}425                        disabled={saving()}426                      />427                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />428                    </div>429                  </Show>430                </div>431432                {/* Subtle trash icon — hidden while editing title */}433                <Show when={editingField() !== "title"}>434                  <Show435                    when={confirmDelete()}436                    fallback={437                      <button438                        class="peach-entry-trash-btn"439                        onClick={() => setConfirmDelete(true)}440                        aria-label="Move to trash"441                        title="Delete entry"442                      >443                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">444                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />445                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />446                        </svg>447                      </button>448                    }449                  >450                    <div class="peach-entry-trash-confirm">451                      <span class="peach-entry-trash-confirm-label">trash?</span>452                      <button453                        class="peach-btn peach-btn--sm peach-btn--danger"454                        style="font-size:10px;padding:2px 8px;min-height:22px;"455                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}456                      >457                        yes458                      </button>459                      <button460                        class="peach-btn peach-btn--ghost peach-btn--sm"461                        style="font-size:10px;padding:2px 8px;min-height:22px;"462                        onClick={() => setConfirmDelete(false)}463                      >464                        no465                      </button>466                    </div>467                  </Show>468                </Show>469              </div>470471              {/* ── Domain / URL ─────────────────────────────────── */}472              <Show when={entry().type !== "note" && entry().type !== "card"}>473                <div class="peach-domain" style="position:relative;z-index:1;">474                  <Show475                    when={editingField() === "url"}476                    fallback={477                      <div478                        class="peach-domain-row peach-inline-clickable"479                        title="Click to edit URL"480                        onClick={() => startEdit("url", entry().url ?? "")}481                      >482                        <Show when={faviconSrc()}>483                          <img484                            src={faviconSrc()!}485                            width="16"486                            height="16"487                            alt=""488                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"489                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}490                          />491                        </Show>492                        <span class="peach-domain-value">493                          {displayDomain() || (entry().url ? entry().url : "add a website url")}494                        </span>495                        <span class="peach-inline-edit-hint">edit</span>496                      </div>497                    }498                  >499                    <div500                      class="peach-inline-field-wrap peach-inline-field-wrap--url"501                      onFocusOut={(event) => handleEditorFocusOut("url", event)}502                    >503                      <div class="peach-inline-field-row">504                        <input505                          class="peach-inline-input"506                          type="url"507                          placeholder="https://"508                          value={draft()}509                          onInput={(e) => setDraft(e.currentTarget.value)}510                          onKeyDown={onEditKeyDown}511                          ref={autoFocus}512                          disabled={saving()}513                        />514                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />515                      </div>516                      <div class="peach-additional-urls">517                        <Show when={additionalUrlsDraft().length > 0}>518                          <span class="peach-label peach-additional-urls-label">also matches</span>519                        </Show>520                        <For each={additionalUrlsDraft()}>521                          {(value, i) => (522                            <div class="peach-additional-url-row">523                              <input524                                class="peach-inline-input"525                                type="url"526                                placeholder="https://"527                                value={value}528                                onInput={(e) => setAdditionalUrlsDraft((arr) => {529                                  const next = [...arr];530                                  next[i()] = e.currentTarget.value;531                                  return next;532                                })}533                                disabled={saving()}534                              />535                              <button536                                class="peach-additional-url-remove"537                                type="button"538                                aria-label="Remove URL"539                                onMouseDown={(e) => e.preventDefault()}540                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}541                                disabled={saving()}542                              >×</button>543                            </div>544                          )}545                        </For>546                        <button547                          class="peach-add-field-link peach-additional-urls-add"548                          type="button"549                          onMouseDown={(e) => e.preventDefault()}550                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}551                          disabled={saving()}552                        >553                          + add another url554                        </button>555                      </div>556                    </div>557                  </Show>558                </div>559              </Show>560561              {/* ── Tags — read-only chips (editing state lives at bottom) */}562              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>563                <div564                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"565                  onClick={startEditTags}566                  title="Click to edit tags"567                >568                  <For each={entry().tags!}>569                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}570                  </For>571                </div>572              </Show>573574              {/* ── Note toolbar + inline editor ─────────────────── */}575              <Show when={entry().type === "note"}>576                <div class="peach-entry-note-toolbar-row">577                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />578                </div>579                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">580                  <NoteEditorInline581                    entryKey={entry().id}582                    initialContent={entry().notes ?? ""}583                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}584                    onBlur={saveNoteInline}585                    autoFocus={false}586                  />587                </section>588              </Show>589590              {/* ── Card detail view ─────────────────────────────── */}591              <Show when={entry().type === "card"}>592                <section class="peach-fields" style="position:relative;z-index:1;">593                  {/* Card subtitle: •••• last4 */}594                  <Show when={entry().last4}>595                    <div class="peach-field-card" style="padding-bottom:4px;">596                      <span class="peach-label">597                        <Show when={entry().network && entry().network !== "unknown"}>598                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>599                        </Show>600                        {entry().cardNickname || `•••• ${entry().last4}`}601                      </span>602                    </div>603                  </Show>604605                  {/* Cardholder name */}606                  <div class="peach-field-card">607                    <span class="peach-label">cardholder name</span>608                    <div class="peach-field-value-row">609                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>610                      <Show when={entry().cardholderName}>611                        <button612                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}613                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}614                          aria-label="Copy cardholder name"615                        >616                          <CopyGlyph /><span class="peach-copy-sheen" />617                        </button>618                      </Show>619                    </div>620                  </div>621622                  {/* Card number */}623                  <div class="peach-field-card">624                    <span class="peach-label">card number</span>625                    <div class="peach-field-value-row">626                      <span class={`peach-field-value peach-card-number`}>627                        <Show when={showCardNumber()} fallback={628                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>629                        }>630                          <span>{entry().cardNumber ?? ""}</span>631                        </Show>632                      </span>633                      <div class="peach-field-actions">634                        <button635                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"636                          onClick={() => setShowCardNumber(!showCardNumber())}637                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}638                        >639                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>640                            <EyeClosedGlyph />641                          </Show>642                        </button>643                        <Show when={entry().cardNumber}>644                          <button645                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}646                            onClick={(e) => {647                              const cardNum = entry().cardNumber!;648                              if (entry().canary?.webhookUrl) {649                                void sendMessage({650                                  type: "FIRE_CANARY_PING",651                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },652                                });653                              }654                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);655                            }}656                            aria-label="Copy card number"657                          >658                            <CopyGlyph /><span class="peach-copy-sheen" />659                          </button>660                        </Show>661                      </div>662                    </div>663                  </div>664665                  {/* Expiry */}666                  <Show when={entry().expMonth || entry().expYear}>667                    <div class="peach-field-card">668                      <span class="peach-label">expiry</span>669                      <div class="peach-field-value-row">670                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>671                        <button672                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}673                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}674                          aria-label="Copy expiry"675                        >676                          <CopyGlyph /><span class="peach-copy-sheen" />677                        </button>678                      </div>679                    </div>680                  </Show>681682                  {/* CVC */}683                  <Show when={entry().cvc}>684                    <div class="peach-field-card">685                      <span class="peach-label">CVC</span>686                      <div class="peach-field-value-row">687                        <span class="peach-field-value">688                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>689                            <span>{entry().cvc}</span>690                          </Show>691                        </span>692                        <div class="peach-field-actions">693                          <button694                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"695                            onClick={() => setShowCardCvc(!showCardCvc())}696                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}697                          >698                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>699                              <EyeClosedGlyph />700                            </Show>701                          </button>702                          <button703                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}704                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}705                            aria-label="Copy CVC"706                          >707                            <CopyGlyph /><span class="peach-copy-sheen" />708                          </button>709                        </div>710                      </div>711                    </div>712                  </Show>713                </section>714              </Show>715716              {/* ── Login fields ─────────────────────────────────── */}717              <Show when={entry().type !== "note" && entry().type !== "card"}>718                <section class="peach-fields" style="position:relative;z-index:1;">719720                  {/* Username */}721                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>722                    <span class="peach-label">username</span>723                    <Show724                      when={editingField() === "username"}725                      fallback={726                        <div class="peach-field-value-row">727                          <span728                            class="peach-field-value peach-inline-clickable"729                            title="Click to edit"730                            onClick={() => startEdit("username", entry().username ?? "")}731                          >732                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}733                          </span>734                          <Show when={entry().username}>735                            <button736                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}737                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}738                              aria-label="Copy username"739                            >740                              <CopyGlyph /><span class="peach-copy-sheen" />741                            </button>742                          </Show>743                        </div>744                      }745                    >746                      <div747                        class="peach-inline-field-wrap"748                        onFocusOut={(event) => handleEditorFocusOut("username", event)}749                      >750                        <input751                          class="peach-inline-input"752                          value={draft()}753                          onInput={(e) => setDraft(e.currentTarget.value)}754                          onKeyDown={onEditKeyDown}755                          ref={autoFocus}756                          disabled={saving()}757                          autocomplete="off"758                        />759                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />760                      </div>761                    </Show>762                  </div>763764                  {/* Password */}765                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>766                    <span class="peach-label">password</span>767                    <Show768                      when={editingField() === "password"}769                      fallback={770                        <div class="peach-field-value-row">771                          <span772                            class="peach-password-value"773                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}774                            style="cursor:pointer;"775                            title="Click to edit password"776                            onClick={() => startEdit("password", entry().password ?? "")}777                          >778                            <Show779                              when={showPassword()}780                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}781                            >782                              <span class="peach-password-text">{entry().password ?? ""}</span>783                            </Show>784                          </span>785                          <div class="peach-field-actions">786                            <button787                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"788                              onClick={() => setShowPassword(!showPassword())}789                              aria-label="Reveal password"790                            >791                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>792                                <EyeClosedGlyph />793                              </Show>794                            </button>795                            <button796                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}797                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}798                              aria-label="Copy password"799                            >800                              <CopyGlyph /><span class="peach-copy-sheen" />801                            </button>802                          </div>803                        </div>804                      }805                    >806                      {/* ── Edit mode: type / paste / generate ── */}807                      <div808                        class="peach-inline-field-wrap"809                        onFocusOut={(event) => handleEditorFocusOut("password", event)}810                      >811                        <div class="peach-password-edit-row">812                          <input813                            class="peach-inline-input"814                            type={showPassword() ? "text" : "password"}815                            placeholder="paste or type a password"816                            value={draft()}817                            onInput={(e) => setDraft(e.currentTarget.value)}818                            onKeyDown={(e) => {819                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }820                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }821                            }}822                            ref={autoFocus}823                            disabled={saving()}824                            autocomplete="new-password"825                          />826                          <button827                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"828                            type="button"829                            onClick={() => setShowPassword(!showPassword())}830                            aria-label={showPassword() ? "Hide" : "Show"}831                          >832                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>833                              <EyeClosedGlyph />834                            </Show>835                          </button>836                        </div>837                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />838                        <button839                          class="peach-generate-toggle-link"840                          type="button"841                          onClick={() => setShowGenerator(!showGenerator())}842                        >843                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">844                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />845                            <path d="M9.5 2.5H11V1" />846                          </svg>847                          {showGenerator() ? "hide generator" : "generate"}848                        </button>849                        <Show when={showGenerator()}>850                          <GeneratorDrawer851                            open={true}852                            compact={true}853                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}854                          />855                        </Show>856                      </div>857                    </Show>858                  </div>859860                </section>861              </Show>862863              {/* ── Notes (on login entries) ──────────────────────── */}864              <Show when={entry().type !== "note" && entry().type !== "card"}>865                <Show866                  when={entry().notes || editingField() === "notes"}867                  fallback={868                    <button869                      class="peach-add-field-link peach-entry-add-link"870                      onClick={() => startEdit("notes", "")}871                    >872                      + notes873                    </button>874                  }875                >876                  <div style="position:relative;z-index:1;margin-top:6px;">877                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>878                    <Show879                      when={editingField() === "notes"}880                      fallback={881                        <div882                          class="peach-note-content peach-inline-clickable"883                          style="cursor:text;white-space:pre-wrap;"884                          onClick={() => startEdit("notes", entry().notes ?? "")}885                        >886                          {entry().notes}887                        </div>888                      }889                    >890                      <div891                        class="peach-inline-field-wrap"892                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}893                      >894                        <textarea895                          class="peach-inline-textarea"896                          value={draft()}897                          onInput={(e) => setDraft(e.currentTarget.value)}898                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}899                          ref={(el) => requestAnimationFrame(() => el?.focus())}900                          rows={4}901                          disabled={saving()}902                        />903                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />904                      </div>905                    </Show>906                  </div>907                </Show>908              </Show>909910              {/* ── TOTP ─────────────────────────────────────────── */}911              <Show when={entry().type === "login"}>912                <Show913                  when={totpState() || editingField() === "totp"}914                  fallback={915                    <button916                      class="peach-add-field-link peach-entry-add-link"917                      onClick={() => startEdit("totp", "")}918                    >919                      + 2FA920                    </button>921                  }922                >923                  <section class="peach-totp-band">924                    <Show925                      when={editingField() === "totp"}926                      fallback={927                        <Show when={totpState()}>928                          {(state) => (929                            <>930                              <div931                                class="peach-totp-main"932                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}933                                onDblClick={() => startEdit("totp", entry().totp ?? "")}934                                title="Click to copy · double-click to edit"935                              >936                                <span class="peach-label">2-factor</span>937                                <div class="peach-totp-code-row">938                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>939                                    <span class="peach-totp-code-text">940                                      {state().code.slice(0, 3)} {state().code.slice(3)}941                                    </span>942                                  </p>943                                </div>944                              </div>945                              <div class="peach-totp-meta">946                                <span class="peach-label">expires</span>947                                <div class="peach-totp-countdown">{state().remaining}s</div>948                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">949                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />950                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"951                                    stroke-dasharray="106.8"952                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}953                                  />954                                </svg>955                              </div>956                            </>957                          )}958                        </Show>959                      }960                    >961                      <div962                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"963                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}964                      >965                        <span class="peach-label">2-factor</span>966                        <input967                          class="peach-inline-input"968                          value={draft()}969                          onInput={(e) => setDraft(e.currentTarget.value)}970                          onKeyDown={onEditKeyDown}971                          ref={autoFocus}972                          disabled={saving()}973                          placeholder="otpauth://... or base32 secret"974                          autocomplete="off"975                        />976                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />977                      </div>978                    </Show>979                  </section>980                </Show>981              </Show>982983              {/* ── Tags editor / add-tags link ──────────────────── */}984              <Show when={entry().type !== "passkey"}>985                <Show986                  when={editingTags()}987                  fallback={988                    <Show when={!entry().tags || entry().tags!.length === 0}>989                      <button990                        class="peach-add-field-link peach-entry-add-link"991                        onClick={startEditTags}992                      >993                        + tags994                      </button>995                    </Show>996                  }997                >998                  <div999                    class="peach-inline-tag-editor"1000                    onFocusOut={(e) => {1001                      const next = e.relatedTarget;1002                      if (next instanceof Node && e.currentTarget.contains(next)) return;1003                      void saveTags();1004                    }}1005                  >1006                    <span class="peach-label">tags</span>1007                    <div class="peach-tag-input-wrap">1008                      <For each={tagsDraft()}>1009                        {(tag) => (1010                          <span class="peach-tag-chip">1011                            {tag}1012                            <button1013                              class="peach-tag-chip-remove"1014                              onMouseDown={(e) => e.preventDefault()}1015                              onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}1016                              aria-label={`Remove ${tag}`}1017                            >×</button>1018                          </span>1019                        )}1020                      </For>1021                      <input1022                        class="peach-tag-input"1023                        placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}1024                        value={tagInput()}1025                        onInput={(e) => setTagInput(e.currentTarget.value)}1026                        onKeyDown={(e) => {1027                          if (e.key === "Enter" || e.key === ",") {1028                            e.preventDefault();1029                            addTag(tagInput());1030                          } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {1031                            setTagsDraft(tagsDraft().slice(0, -1));1032                          } else if (e.key === "Escape") {1033                            e.preventDefault();1034                            setEditingTags(false);1035                            setTagInput("");1036                          }1037                        }}1038                        ref={(el) => requestAnimationFrame(() => el?.focus())}1039                        style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"1040                      />1041                    </div>1042                  </div>1043                </Show>1044              </Show>10451046              {/* ── Bottom spacer ─────────────────────────────────── */}1047              <div style="height:4px;" />1048            </>1049          )}1050        </Show>1051      </div>10521053    </>1054  );1055}10561057// ── Sub-components ────────────────────────────────────────────────────10581059function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1060  return (1061    <div class="peach-inline-actions">1062      <button1063        class="peach-inline-save-btn"1064        onClick={props.onSave}1065        disabled={props.saving}1066        aria-label="Save"1067        title="Save (Enter)"1068      >1069        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1070          <path d="M1 4.5L4 7.5L10 1.5" />1071        </svg>1072      </button>1073      <button1074        class="peach-inline-cancel-btn"1075        onClick={props.onCancel}1076        disabled={props.saving}1077        aria-label="Cancel"1078        title="Cancel (Esc)"1079      >1080        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1081          <path d="M1 1L8 8M8 1L1 8" />1082        </svg>1083      </button>1084    </div>1085  );1086}10871088function EmptyVaultState() {1089  return (1090    <>1091      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1092        <div class="peach-entry-name">1093          <span class="peach-entry-name-text">1094            <span class="peach-entry-name-char" style="--char-index:0">e</span>1095            <span class="peach-entry-name-char" style="--char-index:1">m</span>1096            <span class="peach-entry-name-char" style="--char-index:2">p</span>1097            <span class="peach-entry-name-char" style="--char-index:3">t</span>1098            <span class="peach-entry-name-char" style="--char-index:4">y</span>1099          </span>1100        </div>1101      </div>1102      <div class="peach-empty-copy">1103        <span class="peach-label">vault</span>1104        <p>Your vault is ready for its first entry.</p>1105      </div>1106    </>1107  );1108}11091110function CopyGlyph() {1111  return (1112    <span class="peach-copy-icon">1113      <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">1114        <rect x="4" y="4" width="8" height="9" rx="1.5" />1115        <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" />1116      </svg>1117    </span>1118  );1119}11201121function EyeClosedGlyph() {1122  return (1123    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1124      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1125      <path d="M3.5 5.5L2.5 7.5" />1126      <path d="M7 6.5V8.5" />1127      <path d="M10.5 5.5L11.5 7.5" />1128    </svg>1129  );1130}11311132function EyeOpenGlyph() {1133  return (1134    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1135      <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" />1136      <circle cx="7" cy="5" r="1.75" />1137    </svg>1138  );1139}11401141function ChevronGlyph(props: { open: boolean }) {1142  return (1143    <svg1144      width="8"1145      height="8"1146      viewBox="0 0 8 8"1147      fill="none"1148      stroke="currentColor"1149      stroke-width="1.3"1150      stroke-linecap="round"1151      stroke-linejoin="round"1152      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1153    >1154      <path d="M2 1L6 4L2 7" />1155    </svg>1156  );1157}11581159function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1160  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1161  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1162  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1163  const [testResult, setTestResult] = createSignal<string | null>(null);1164  const [testing, setTesting] = createSignal(false);11651166  async function save() {1167    const url = webhookUrl().trim();1168    const nextLabel = label().trim() || undefined;1169    const canary = enabled() && url1170      ? {1171          webhookUrl: url,1172          label: nextLabel,1173          lastPinged: props.entry.canary?.lastPinged,1174          pingCount: props.entry.canary?.pingCount,1175        }1176      : undefined;1177    await sendMessage({1178      type: "UPDATE_ENTRY",1179      payload: { id: props.entry.id, patch: { canary } },1180    });1181    await props.onSaved();1182  }11831184  async function testPing() {1185    const url = webhookUrl().trim();1186    if (!url) return;1187    setTesting(true);1188    setTestResult(null);1189    const res = await sendMessage({1190      type: "FIRE_CANARY_PING",1191      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1192    }) as { ok: boolean; error?: string };1193    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1194    setTesting(false);1195  }11961197  return (1198    <div class="peach-canary-section">1199      <div class="peach-field-value-row">1200        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1201        <button1202          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1203          onClick={() => { setEnabled(!enabled()); void save(); }}1204          aria-label="Toggle canary"1205        >1206          <span class="peach-toggle-knob" />1207        </button>1208      </div>1209      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1210        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1211      </p>1212      <Show when={enabled()}>1213        <div class="peach-inline-field-wrap" style="margin-top:8px;">1214          <input1215            class="peach-inline-input"1216            placeholder="https://hooks.zapier.com/..."1217            value={webhookUrl()}1218            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1219            onBlur={() => void save()}1220          />1221          <input1222            class="peach-inline-input"1223            placeholder="label (optional, e.g. AWS decoy)"1224            value={label()}1225            onInput={(e) => setLabel(e.currentTarget.value)}1226            onBlur={() => void save()}1227          />1228          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1229            <button1230              class="peach-btn peach-btn--ghost peach-btn--sm"1231              onClick={() => void testPing()}1232              disabled={testing() || !webhookUrl().trim()}1233            >1234              {testing() ? "sending..." : "test ping"}1235            </button>1236            <Show when={testResult()}>1237              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1238            </Show>1239          </div>1240          <Show when={props.entry.canary?.lastPinged}>1241            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1242              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1243              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1244            </p>1245          </Show>1246        </div>1247      </Show>1248    </div>1249  );1250}