Entry.tsx

49.5 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                    <div456                      class="peach-inline-field-wrap peach-inline-field-wrap--url"457                      onFocusOut={(event) => handleEditorFocusOut("url", event)}458                    >459                      <div class="peach-inline-field-row">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                                onMouseDown={(e) => e.preventDefault()}496                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}497                                disabled={saving()}498                              >×</button>499                            </div>500                          )}501                        </For>502                        <button503                          class="peach-add-field-link peach-additional-urls-add"504                          type="button"505                          onMouseDown={(e) => e.preventDefault()}506                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}507                          disabled={saving()}508                        >509                          + add another url510                        </button>511                      </div>512                    </div>513                  </Show>514                </div>515              </Show>516517              {/* ── Tags ─────────────────────────────────────────── */}518              <Show when={entry().tags && entry().tags!.length > 0}>519                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">520                  <For each={entry().tags!}>521                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}522                  </For>523                </div>524              </Show>525526              {/* ── Note toolbar + inline editor ─────────────────── */}527              <Show when={entry().type === "note"}>528                <div class="peach-entry-note-toolbar-row">529                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />530                </div>531                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">532                  <NoteEditorInline533                    entryKey={entry().id}534                    initialContent={entry().notes ?? ""}535                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}536                    onBlur={saveNoteInline}537                    autoFocus={false}538                  />539                </section>540              </Show>541542              {/* ── Card detail view ─────────────────────────────── */}543              <Show when={entry().type === "card"}>544                <section class="peach-fields" style="position:relative;z-index:1;">545                  {/* Card subtitle: •••• last4 */}546                  <Show when={entry().last4}>547                    <div class="peach-field-card" style="padding-bottom:4px;">548                      <span class="peach-label">549                        <Show when={entry().network && entry().network !== "unknown"}>550                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>551                        </Show>552                        {entry().cardNickname || `•••• ${entry().last4}`}553                      </span>554                    </div>555                  </Show>556557                  {/* Cardholder name */}558                  <div class="peach-field-card">559                    <span class="peach-label">cardholder name</span>560                    <div class="peach-field-value-row">561                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>562                      <Show when={entry().cardholderName}>563                        <button564                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}565                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}566                          aria-label="Copy cardholder name"567                        >568                          <CopyGlyph /><span class="peach-copy-sheen" />569                        </button>570                      </Show>571                    </div>572                  </div>573574                  {/* Card number */}575                  <div class="peach-field-card">576                    <span class="peach-label">card number</span>577                    <div class="peach-field-value-row">578                      <span class={`peach-field-value peach-card-number`}>579                        <Show when={showCardNumber()} fallback={580                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>581                        }>582                          <span>{entry().cardNumber ?? ""}</span>583                        </Show>584                      </span>585                      <div class="peach-field-actions">586                        <button587                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"588                          onClick={() => setShowCardNumber(!showCardNumber())}589                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}590                        >591                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>592                            <EyeClosedGlyph />593                          </Show>594                        </button>595                        <Show when={entry().cardNumber}>596                          <button597                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}598                            onClick={(e) => {599                              const cardNum = entry().cardNumber!;600                              if (entry().canary?.webhookUrl) {601                                void sendMessage({602                                  type: "FIRE_CANARY_PING",603                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },604                                });605                              }606                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);607                            }}608                            aria-label="Copy card number"609                          >610                            <CopyGlyph /><span class="peach-copy-sheen" />611                          </button>612                        </Show>613                      </div>614                    </div>615                  </div>616617                  {/* Expiry */}618                  <Show when={entry().expMonth || entry().expYear}>619                    <div class="peach-field-card">620                      <span class="peach-label">expiry</span>621                      <div class="peach-field-value-row">622                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>623                        <button624                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}625                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}626                          aria-label="Copy expiry"627                        >628                          <CopyGlyph /><span class="peach-copy-sheen" />629                        </button>630                      </div>631                    </div>632                  </Show>633634                  {/* CVC */}635                  <Show when={entry().cvc}>636                    <div class="peach-field-card">637                      <span class="peach-label">CVC</span>638                      <div class="peach-field-value-row">639                        <span class="peach-field-value">640                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>641                            <span>{entry().cvc}</span>642                          </Show>643                        </span>644                        <div class="peach-field-actions">645                          <button646                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"647                            onClick={() => setShowCardCvc(!showCardCvc())}648                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}649                          >650                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>651                              <EyeClosedGlyph />652                            </Show>653                          </button>654                          <button655                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}656                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}657                            aria-label="Copy CVC"658                          >659                            <CopyGlyph /><span class="peach-copy-sheen" />660                          </button>661                        </div>662                      </div>663                    </div>664                  </Show>665                </section>666              </Show>667668              {/* ── Login fields ─────────────────────────────────── */}669              <Show when={entry().type !== "note" && entry().type !== "card"}>670                <section class="peach-fields" style="position:relative;z-index:1;">671672                  {/* Username */}673                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>674                    <span class="peach-label">username</span>675                    <Show676                      when={editingField() === "username"}677                      fallback={678                        <div class="peach-field-value-row">679                          <span680                            class="peach-field-value peach-inline-clickable"681                            title="Click to edit"682                            onClick={() => startEdit("username", entry().username ?? "")}683                          >684                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}685                          </span>686                          <Show when={entry().username}>687                            <button688                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}689                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}690                              aria-label="Copy username"691                            >692                              <CopyGlyph /><span class="peach-copy-sheen" />693                            </button>694                          </Show>695                        </div>696                      }697                    >698                      <div699                        class="peach-inline-field-wrap"700                        onFocusOut={(event) => handleEditorFocusOut("username", event)}701                      >702                        <input703                          class="peach-inline-input"704                          value={draft()}705                          onInput={(e) => setDraft(e.currentTarget.value)}706                          onKeyDown={onEditKeyDown}707                          ref={autoFocus}708                          disabled={saving()}709                          autocomplete="off"710                        />711                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />712                      </div>713                    </Show>714                  </div>715716                  {/* Password */}717                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>718                    <span class="peach-label">password</span>719                    <Show720                      when={editingField() === "password"}721                      fallback={722                        <div class="peach-field-value-row">723                          <span724                            class="peach-password-value"725                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}726                            style="cursor:pointer;"727                            title="Click to edit password"728                            onClick={() => startEdit("password", entry().password ?? "")}729                          >730                            <Show731                              when={showPassword()}732                              fallback={<span class="peach-password-mask">...............</span>}733                            >734                              <span class="peach-password-text">{entry().password ?? ""}</span>735                            </Show>736                          </span>737                          <div class="peach-field-actions">738                            <button739                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"740                              onClick={() => setShowPassword(!showPassword())}741                              aria-label="Reveal password"742                            >743                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>744                                <EyeClosedGlyph />745                              </Show>746                            </button>747                            <button748                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}749                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}750                              aria-label="Copy password"751                            >752                              <CopyGlyph /><span class="peach-copy-sheen" />753                            </button>754                          </div>755                        </div>756                      }757                    >758                      {/* ── Edit mode: type / paste / generate ── */}759                      <div760                        class="peach-inline-field-wrap"761                        onFocusOut={(event) => handleEditorFocusOut("password", event)}762                      >763                        <div class="peach-password-edit-row">764                          <input765                            class="peach-inline-input"766                            type={showPassword() ? "text" : "password"}767                            placeholder="paste or type a password"768                            value={draft()}769                            onInput={(e) => setDraft(e.currentTarget.value)}770                            onKeyDown={(e) => {771                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }772                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }773                            }}774                            ref={autoFocus}775                            disabled={saving()}776                            autocomplete="new-password"777                          />778                          <button779                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"780                            type="button"781                            onClick={() => setShowPassword(!showPassword())}782                            aria-label={showPassword() ? "Hide" : "Show"}783                          >784                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>785                              <EyeClosedGlyph />786                            </Show>787                          </button>788                        </div>789                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />790                        <button791                          class="peach-generate-toggle-link"792                          type="button"793                          onClick={() => setShowGenerator(!showGenerator())}794                        >795                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">796                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />797                            <path d="M9.5 2.5H11V1" />798                          </svg>799                          {showGenerator() ? "hide generator" : "generate"}800                        </button>801                        <Show when={showGenerator()}>802                          <GeneratorDrawer803                            open={true}804                            compact={true}805                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}806                          />807                        </Show>808                      </div>809                    </Show>810                  </div>811812                </section>813              </Show>814815              {/* ── Notes (on login entries) ──────────────────────── */}816              <Show when={entry().type !== "note" && entry().type !== "card"}>817                <Show818                  when={entry().notes || editingField() === "notes"}819                  fallback={820                    <button821                      class="peach-add-field-link peach-entry-add-link"822                      onClick={() => startEdit("notes", "")}823                    >824                      + add notes825                    </button>826                  }827                >828                  <div style="position:relative;z-index:1;margin-top:6px;">829                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>830                    <Show831                      when={editingField() === "notes"}832                      fallback={833                        <div834                          class="peach-note-content peach-inline-clickable"835                          style="cursor:text;white-space:pre-wrap;"836                          onClick={() => startEdit("notes", entry().notes ?? "")}837                        >838                          {entry().notes}839                        </div>840                      }841                    >842                      <div843                        class="peach-inline-field-wrap"844                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}845                      >846                        <textarea847                          class="peach-inline-textarea"848                          value={draft()}849                          onInput={(e) => setDraft(e.currentTarget.value)}850                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}851                          ref={(el) => requestAnimationFrame(() => el?.focus())}852                          rows={4}853                          disabled={saving()}854                        />855                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />856                      </div>857                    </Show>858                  </div>859                </Show>860              </Show>861862              {/* ── TOTP ─────────────────────────────────────────── */}863              <Show when={entry().type === "login"}>864                <Show865                  when={totpState() || editingField() === "totp"}866                  fallback={867                    <button868                      class="peach-add-field-link peach-entry-add-link"869                      onClick={() => startEdit("totp", "")}870                    >871                      + add 2FA872                    </button>873                  }874                >875                  <section class="peach-totp-band">876                    <Show877                      when={editingField() === "totp"}878                      fallback={879                        <Show when={totpState()}>880                          {(state) => (881                            <>882                              <div883                                class="peach-totp-main"884                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}885                                onDblClick={() => startEdit("totp", entry().totp ?? "")}886                                title="Click to copy · double-click to edit"887                              >888                                <span class="peach-label">2-factor</span>889                                <div class="peach-totp-code-row">890                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>891                                    <span class="peach-totp-code-text">892                                      {state().code.slice(0, 3)} {state().code.slice(3)}893                                    </span>894                                  </p>895                                </div>896                              </div>897                              <div class="peach-totp-meta">898                                <span class="peach-label">expires</span>899                                <div class="peach-totp-countdown">{state().remaining}s</div>900                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">901                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />902                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"903                                    stroke-dasharray="106.8"904                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}905                                  />906                                </svg>907                              </div>908                            </>909                          )}910                        </Show>911                      }912                    >913                      <div914                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"915                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}916                      >917                        <span class="peach-label">2-factor</span>918                        <input919                          class="peach-inline-input"920                          value={draft()}921                          onInput={(e) => setDraft(e.currentTarget.value)}922                          onKeyDown={onEditKeyDown}923                          ref={autoFocus}924                          disabled={saving()}925                          placeholder="otpauth://... or base32 secret"926                          autocomplete="off"927                        />928                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />929                      </div>930                    </Show>931                  </section>932                </Show>933              </Show>934935              {/* ── Bottom spacer ─────────────────────────────────── */}936              <div style="height:4px;" />937            </>938          )}939        </Show>940      </div>941942    </>943  );944}945946// ── Sub-components ────────────────────────────────────────────────────947948function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {949  return (950    <div class="peach-inline-actions">951      <button952        class="peach-inline-save-btn"953        onClick={props.onSave}954        disabled={props.saving}955        aria-label="Save"956        title="Save (Enter)"957      >958        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">959          <path d="M1 4.5L4 7.5L10 1.5" />960        </svg>961      </button>962      <button963        class="peach-inline-cancel-btn"964        onClick={props.onCancel}965        disabled={props.saving}966        aria-label="Cancel"967        title="Cancel (Esc)"968      >969        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">970          <path d="M1 1L8 8M8 1L1 8" />971        </svg>972      </button>973    </div>974  );975}976977function EmptyVaultState() {978  return (979    <>980      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">981        <div class="peach-entry-name">982          <span class="peach-entry-name-text">983            <span class="peach-entry-name-char" style="--char-index:0">e</span>984            <span class="peach-entry-name-char" style="--char-index:1">m</span>985            <span class="peach-entry-name-char" style="--char-index:2">p</span>986            <span class="peach-entry-name-char" style="--char-index:3">t</span>987            <span class="peach-entry-name-char" style="--char-index:4">y</span>988          </span>989        </div>990      </div>991      <div class="peach-empty-copy">992        <span class="peach-label">vault</span>993        <p>Your vault is ready for its first entry.</p>994      </div>995    </>996  );997}998999function CopyGlyph() {1000  return (1001    <span class="peach-copy-icon">1002      <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">1003        <rect x="4" y="4" width="8" height="9" rx="1.5" />1004        <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" />1005      </svg>1006    </span>1007  );1008}10091010function EyeClosedGlyph() {1011  return (1012    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1013      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1014      <path d="M3.5 5.5L2.5 7.5" />1015      <path d="M7 6.5V8.5" />1016      <path d="M10.5 5.5L11.5 7.5" />1017    </svg>1018  );1019}10201021function EyeOpenGlyph() {1022  return (1023    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1024      <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" />1025      <circle cx="7" cy="5" r="1.75" />1026    </svg>1027  );1028}10291030function ChevronGlyph(props: { open: boolean }) {1031  return (1032    <svg1033      width="8"1034      height="8"1035      viewBox="0 0 8 8"1036      fill="none"1037      stroke="currentColor"1038      stroke-width="1.3"1039      stroke-linecap="round"1040      stroke-linejoin="round"1041      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1042    >1043      <path d="M2 1L6 4L2 7" />1044    </svg>1045  );1046}10471048function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1049  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1050  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1051  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1052  const [testResult, setTestResult] = createSignal<string | null>(null);1053  const [testing, setTesting] = createSignal(false);10541055  async function save() {1056    const url = webhookUrl().trim();1057    const nextLabel = label().trim() || undefined;1058    const canary = enabled() && url1059      ? {1060          webhookUrl: url,1061          label: nextLabel,1062          lastPinged: props.entry.canary?.lastPinged,1063          pingCount: props.entry.canary?.pingCount,1064        }1065      : undefined;1066    await sendMessage({1067      type: "UPDATE_ENTRY",1068      payload: { id: props.entry.id, patch: { canary } },1069    });1070    await props.onSaved();1071  }10721073  async function testPing() {1074    const url = webhookUrl().trim();1075    if (!url) return;1076    setTesting(true);1077    setTestResult(null);1078    const res = await sendMessage({1079      type: "FIRE_CANARY_PING",1080      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1081    }) as { ok: boolean; error?: string };1082    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1083    setTesting(false);1084  }10851086  return (1087    <div class="peach-canary-section">1088      <div class="peach-field-value-row">1089        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1090        <button1091          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1092          onClick={() => { setEnabled(!enabled()); void save(); }}1093          aria-label="Toggle canary"1094        >1095          <span class="peach-toggle-knob" />1096        </button>1097      </div>1098      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1099        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1100      </p>1101      <Show when={enabled()}>1102        <div class="peach-inline-field-wrap" style="margin-top:8px;">1103          <input1104            class="peach-inline-input"1105            placeholder="https://hooks.zapier.com/..."1106            value={webhookUrl()}1107            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1108            onBlur={() => void save()}1109          />1110          <input1111            class="peach-inline-input"1112            placeholder="label (optional, e.g. AWS decoy)"1113            value={label()}1114            onInput={(e) => setLabel(e.currentTarget.value)}1115            onBlur={() => void save()}1116          />1117          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1118            <button1119              class="peach-btn peach-btn--ghost peach-btn--sm"1120              onClick={() => void testPing()}1121              disabled={testing() || !webhookUrl().trim()}1122            >1123              {testing() ? "sending..." : "test ping"}1124            </button>1125            <Show when={testResult()}>1126              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1127            </Show>1128          </div>1129          <Show when={props.entry.canary?.lastPinged}>1130            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1131              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1132              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1133            </p>1134          </Show>1135        </div>1136      </Show>1137    </div>1138  );1139}