Entry.tsx

58.1 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { isPasskeyOnly } from "../../shared/types";4import { detectBrowser } from "../../shared/browser";5import { getDisplayDomain } from "../../shared/domain";6import { faviconUrl } from "../../shared/favicon";7import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";8import { sendMessage, triggerPipPulse, triggerRing } from "../App";9import { popupCopy } from "../copy";10import { GeneratorDrawer } from "../components/GeneratorDrawer";11import { NoteEditorInline, Toolbar } from "./NoteEditor";1213type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1415interface EntryProps {16  entry: VaultEntry | null;17  onDelete: () => void;18  /** Called after any inline save so App can reload entries */19  onFieldSaved: () => void | Promise<void>;20  onEscape?: () => void;21}2223export function EntryView(props: EntryProps) {24  const [editingField, setEditingField] = createSignal<EditableField>(null);25  const [draft, setDraft] = createSignal("");26  const [saving, setSaving] = createSignal(false);27  const [showPassword, setShowPassword] = createSignal(false);28  const [showGenerator, setShowGenerator] = createSignal(false);29  const [copied, setCopied] = createSignal<string | null>(null);30  // Local draft for the additional URLs list, edited only while the URL field31  // is in inline-edit mode. Saved alongside `url` when the user confirms.32  const [additionalUrlsDraft, setAdditionalUrlsDraft] = createSignal<string[]>([]);33  const [editingTags, setEditingTags] = createSignal(false);34  const [tagsDraft, setTagsDraft] = createSignal<string[]>([]);35  const [tagInput, setTagInput] = createSignal("");36  const [showCardNumber, setShowCardNumber] = createSignal(false);37  const [showCardCvc, setShowCardCvc] = createSignal(false);38  const [totpTick, setTotpTick] = createSignal(0);39  const [confirmDelete, setConfirmDelete] = createSignal(false);40  const [totpHot, setTotpHot] = createSignal(false);41  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);42  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);43  let lastSavedNote: string | null = null;4445  function noteExec(cmd: string, val?: string) {46    document.execCommand(cmd, false, val);47    noteEditorEl()?.focus();48  }4950  async function saveNoteInline(html: string) {51    const entry = props.entry;52    if (!entry) return;53    const currentStored = entry.notes ?? "";54    if (html === currentStored) return;55    if (lastSavedNote !== null && html === lastSavedNote) return;56    lastSavedNote = html;57    setSaving(true);58    try {59      await sendMessage({60        type: "UPDATE_ENTRY",61        payload: { id: entry.id, patch: { notes: html } },62      });63      triggerRing();64      await props.onFieldSaved();65    } catch {66      lastSavedNote = null;67    } finally {68      setSaving(false);69    }70  }7172  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);73  onCleanup(() => window.clearInterval(interval));7475  const displayDomain = createMemo(() =>76    props.entry?.url ? getDisplayDomain(props.entry.url) : ""77  );7879  createEffect(() => {80    const domain = displayDomain();81    setFaviconSrc(domain ? faviconUrl(domain) : null);82  });8384  // Reset transient prompts (trash confirmation, inline edits) when the85  // selected entry changes so they don't carry over between entries.86  createEffect(() => {87    void props.entry?.id;88    setConfirmDelete(false);89    setEditingField(null);90    setEditingTags(false);91  });9293  const totpState = createMemo(() => {94    void totpTick();95    if (!props.entry?.totp) return null;96    return generateTOTP(props.entry.totp);97  });9899  let prevTotpCode: string | undefined;100  createEffect(() => {101    const state = totpState();102    if (!state) { prevTotpCode = undefined; return; }103    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {104      setTotpHot(true);105      window.setTimeout(() => setTotpHot(false), 3000);106    }107    prevTotpCode = state.code;108  });109110  const totpProgress = createMemo(() => {111    const state = totpState();112    if (!state) return 0;113    return ((state.remaining - 1) / 29) * 100;114  });115116  createEffect(() => {117    const shell = document.getElementById("hd-shell");118    if (!shell) return;119120    const state = totpState();121    if (!state) {122      delete shell.dataset.totpActive;123      shell.style.removeProperty("--peach-totp-progress");124      return;125    }126127    shell.dataset.totpActive = "true";128    shell.style.setProperty(129      "--peach-totp-progress",130      `${Math.max(0, totpProgress())}%`131    );132133    onCleanup(() => {134      delete shell.dataset.totpActive;135      shell.style.removeProperty("--peach-totp-progress");136    });137  });138139  const titleSizeClass = createMemo(() => {140    const length = props.entry?.title.length ?? 0;141    if (length >= 28) return "peach-entry-name--xxl";142    if (length >= 20) return "peach-entry-name--xl";143    if (length >= 14) return "peach-entry-name--lg";144    return "";145  });146  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);147148  // ── Inline editing ────────────────────────────────────────────────149150  function startEdit(field: EditableField, value: string) {151    setEditingField(field);152    setDraft(value);153    setShowGenerator(false);154    setConfirmDelete(false);155    if (field === "url") {156      setAdditionalUrlsDraft([...(props.entry?.additionalUrls ?? [])]);157    }158  }159160  async function saveField() {161    const field = editingField();162    const entry = props.entry;163    if (!field || !entry) return;164    // Don't trim passwords — spaces may be intentional165    let value = field === "password" ? draft() : draft().trim();166    if (field === "totp" && value.startsWith("otpauth://")) {167      const parsed = parseOtpauthURI(value);168      value = parsed?.secret ?? value;169    }170    if (field === "password" && entry.type !== "note" && !value) {171      return;172    }173    setSaving(true);174    try {175      const patch: Partial<VaultEntry> = { [field]: value };176      if (field === "url") {177        // Sanitization layer in vault.ts trims, dedupes, and drops empties.178        patch.additionalUrls = additionalUrlsDraft();179      }180      await sendMessage({181        type: "UPDATE_ENTRY",182        payload: { id: entry.id, patch },183      });184      triggerRing();185      setEditingField(null);186      setShowGenerator(false);187      await props.onFieldSaved();188    } catch {189      // silent — leave editing open190    } finally {191      setSaving(false);192    }193  }194195  function startEditTags() {196    setTagsDraft([...(props.entry?.tags ?? [])]);197    setTagInput("");198    setEditingTags(true);199  }200201  function addTag(value: string) {202    const n = value.toLowerCase().trim();203    if (!n || n.length > 32 || tagsDraft().includes(n) || tagsDraft().length >= 10) return;204    setTagsDraft([...tagsDraft(), n]);205    setTagInput("");206  }207208  async function saveTags() {209    if (tagInput()) addTag(tagInput());210    const entry = props.entry;211    if (!entry) return;212    const next = tagsDraft().length > 0 ? tagsDraft() : undefined;213    setEditingTags(false);214    setSaving(true);215    try {216      await sendMessage({217        type: "UPDATE_ENTRY",218        payload: { id: entry.id, patch: { tags: next } },219      });220      triggerRing();221      await props.onFieldSaved();222    } catch {223      // silent224    } finally {225      setSaving(false);226    }227  }228229  function cancelEdit() {230    setEditingField(null);231    setDraft("");232    setShowGenerator(false);233  }234235  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {236    switch (field) {237      case "title":238        return entry.title;239      case "url":240        return entry.url ?? "";241      case "username":242        return entry.username ?? "";243      case "password":244        return entry.password ?? "";245      case "notes":246        return entry.notes ?? "";247      case "totp":248        return entry.totp ?? "";249    }250  }251252  function handleEditorFocusOut(253    field: Exclude<EditableField, null>,254    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }255  ) {256    const nextTarget = event.relatedTarget;257    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;258259    const entry = props.entry;260    if (!entry) return;261262    const currentValue = fieldValue(entry, field);263    const nextValue = field === "password" ? draft() : draft().trim();264    if (nextValue === currentValue) {265      cancelEdit();266    }267  }268269  async function savePassword(password: string) {270    const entry = props.entry;271    if (!entry) return;272    setSaving(true);273    try {274      await sendMessage({275        type: "UPDATE_ENTRY",276        payload: { id: entry.id, patch: { password } },277      });278      triggerRing();279      setShowGenerator(false);280      await props.onFieldSaved();281    } catch {282      // silent283    } finally {284      setSaving(false);285    }286  }287288  // ── Copy ─────────────────────────────────────────────────────────289290  async function copyField(text: string, label: string, button?: HTMLButtonElement) {291    const ok = await popupCopy(text, label);292    if (ok) {293      const entry = props.entry;294      if (entry?.canary?.webhookUrl) {295        const trigger =296          label === "password"297            ? "password_copied"298            : label === "username"299              ? "username_copied"300              : undefined;301        if (trigger) {302          void sendMessage({303            type: "FIRE_CANARY_PING",304            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },305          });306        }307      }308      triggerRing();309      triggerPipPulse();310      setCopied(null);311      requestAnimationFrame(() => setCopied(label));312      if (button) {313        button.classList.remove("is-copied", "is-retreat");314        void button.offsetWidth;315        const drain = document.createElement("span");316        drain.className = "peach-copy-drain";317        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";318        drain.appendChild(bar);319        button.appendChild(drain);320        window.setTimeout(() => {321          button.classList.remove("is-copied");322          button.classList.add("is-retreat");323          drain.remove();324          window.setTimeout(() => button.classList.remove("is-retreat"), 160);325        }, 1800);326      }327      window.setTimeout(() => setCopied(null), 1800);328    }329  }330331  // ── Keyboard shortcuts ────────────────────────────────────────────332333  createEffect(() => {334    if (!props.entry) return;335    const handler = (e: KeyboardEvent) => {336      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable;337      if (inInput) return;338      switch (e.key) {339        case "c": e.preventDefault();340          if (props.entry?.password) void copyField(props.entry.password, "password"); break;341        case "u": e.preventDefault();342          if (props.entry?.username) void copyField(props.entry.username, "username"); break;343        case "Escape": e.preventDefault(); props.onEscape?.(); break;344      }345    };346    window.addEventListener("keydown", handler);347    onCleanup(() => window.removeEventListener("keydown", handler));348  });349350  // ── Input ref focus helper ────────────────────────────────────────351352  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {353    requestAnimationFrame(() => { el.focus(); el.select?.(); });354  }355356  function onEditKeyDown(e: KeyboardEvent) {357    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {358      e.preventDefault();359      void saveField();360    }361    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }362  }363364  return (365    <>366      <div367        class="peach-ghost"368        aria-hidden="true"369        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;"370      >371        {props.entry?.title || "empty"}372      </div>373374      <div class="peach-stage" style="position:relative;">375        <Show when={props.entry} fallback={<EmptyVaultState />}>376          {(entry) => (377            <>378              {/* ── Breach strip ─────────────────────────────────── */}379              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>380                <div class="peach-breach-badge">381                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">382                    <path d="M6 1L11 10H1L6 1z" />383                    <line x1="6" y1="5" x2="6" y2="7.5" />384                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />385                  </svg>386                  <span style="flex:1">found in a known data breach</span>387                  <button388                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"389                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}390                  >391                    Change password392                  </button>393                </div>394              </Show>395396              {/* ── Title + trash ─────────────────────────────────── */}397              <div class="peach-entry-header">398                <div class="peach-entry-header-name">399                  <Show400                    when={editingField() === "title"}401                    fallback={402                      <button403                        class="peach-entry-name-button"404                        title="Click to edit title"405                        onClick={() => startEdit("title", entry().title)}406                      >407                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>408                          <span class="peach-entry-name-text">409                            <Show410                              when={useAnimatedTitle()}411                              fallback={entry().title}412                            >413                              <For each={entry().title.split("")}>414                                {(ch, i) => (415                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>416                                    {ch === " " ? "\u00A0" : ch}417                                  </span>418                                )}419                              </For>420                            </Show>421                          </span>422                        </h1>423                      </button>424                    }425                  >426                    <div427                      class="peach-inline-title-wrap"428                      onFocusOut={(event) => handleEditorFocusOut("title", event)}429                    >430                      <input431                        class="peach-inline-title-input"432                        value={draft()}433                        onInput={(e) => setDraft(e.currentTarget.value)}434                        onKeyDown={onEditKeyDown}435                        ref={autoFocus}436                        disabled={saving()}437                      />438                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />439                    </div>440                  </Show>441                </div>442443                {/* Subtle trash icon — hidden while editing title */}444                <Show when={editingField() !== "title"}>445                  <Show446                    when={confirmDelete()}447                    fallback={448                      <button449                        class="peach-entry-trash-btn"450                        onClick={() => setConfirmDelete(true)}451                        aria-label="Move to trash"452                        title="Delete entry"453                      >454                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">455                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />456                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />457                        </svg>458                      </button>459                    }460                  >461                    <div class="peach-entry-trash-confirm">462                      <span class="peach-entry-trash-confirm-label">trash?</span>463                      <button464                        class="peach-btn peach-btn--sm peach-btn--danger"465                        style="font-size:10px;padding:2px 8px;min-height:22px;"466                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}467                      >468                        yes469                      </button>470                      <button471                        class="peach-btn peach-btn--ghost peach-btn--sm"472                        style="font-size:10px;padding:2px 8px;min-height:22px;"473                        onClick={() => setConfirmDelete(false)}474                      >475                        no476                      </button>477                    </div>478                  </Show>479                </Show>480              </div>481482              {/* ── Domain / URL ─────────────────────────────────── */}483              <Show when={entry().type !== "note" && entry().type !== "card"}>484                <div class="peach-domain" style="position:relative;z-index:1;">485                  <Show486                    when={editingField() === "url"}487                    fallback={488                      <div489                        class="peach-domain-row peach-inline-clickable"490                        title="Click to edit URL"491                        onClick={() => startEdit("url", entry().url ?? "")}492                      >493                        <Show when={faviconSrc()}>494                          <img495                            src={faviconSrc()!}496                            width="16"497                            height="16"498                            alt=""499                            style="border-radius:3px;margin-right:5px;flex-shrink:0;"500                            onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}501                          />502                        </Show>503                        <span class="peach-domain-value">504                          {displayDomain() || (entry().url ? entry().url : "add a website url")}505                        </span>506                        <span class="peach-inline-edit-hint">edit</span>507                      </div>508                    }509                  >510                    <div511                      class="peach-inline-field-wrap peach-inline-field-wrap--url"512                      onFocusOut={(event) => handleEditorFocusOut("url", event)}513                    >514                      <div class="peach-inline-field-row">515                        <input516                          class="peach-inline-input"517                          type="url"518                          placeholder="https://"519                          value={draft()}520                          onInput={(e) => setDraft(e.currentTarget.value)}521                          onKeyDown={onEditKeyDown}522                          ref={autoFocus}523                          disabled={saving()}524                        />525                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />526                      </div>527                      <div class="peach-additional-urls">528                        <Show when={additionalUrlsDraft().length > 0}>529                          <span class="peach-label peach-additional-urls-label">also matches</span>530                        </Show>531                        <For each={additionalUrlsDraft()}>532                          {(value, i) => (533                            <div class="peach-additional-url-row">534                              <input535                                class="peach-inline-input"536                                type="url"537                                placeholder="https://"538                                value={value}539                                onInput={(e) => setAdditionalUrlsDraft((arr) => {540                                  const next = [...arr];541                                  next[i()] = e.currentTarget.value;542                                  return next;543                                })}544                                disabled={saving()}545                              />546                              <button547                                class="peach-additional-url-remove"548                                type="button"549                                aria-label="Remove URL"550                                onMouseDown={(e) => e.preventDefault()}551                                onClick={() => setAdditionalUrlsDraft((arr) => arr.filter((_, idx) => idx !== i()))}552                                disabled={saving()}553                              >×</button>554                            </div>555                          )}556                        </For>557                        <button558                          class="peach-add-field-link peach-additional-urls-add"559                          type="button"560                          onMouseDown={(e) => e.preventDefault()}561                          onClick={() => setAdditionalUrlsDraft((arr) => [...arr, ""])}562                          disabled={saving()}563                        >564                          + add another url565                        </button>566                      </div>567                    </div>568                  </Show>569                </div>570              </Show>571572              {/* ── Tags — read-only chips (editing state lives at bottom) */}573              <Show when={!editingTags() && entry().tags && entry().tags!.length > 0}>574                <div575                  style="display:flex;flex-wrap:wrap;gap:5px;margin:2px 0;position:relative;z-index:1;cursor:pointer;"576                  onClick={startEditTags}577                  title="Click to edit tags"578                >579                  <For each={entry().tags!}>580                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}581                  </For>582                </div>583              </Show>584585              {/* ── Note toolbar + inline editor ─────────────────── */}586              <Show when={entry().type === "note"}>587                <div class="peach-entry-note-toolbar-row">588                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />589                </div>590                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">591                  <NoteEditorInline592                    entryKey={entry().id}593                    initialContent={entry().notes ?? ""}594                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}595                    onBlur={saveNoteInline}596                    autoFocus={false}597                  />598                </section>599              </Show>600601              {/* ── Card detail view ─────────────────────────────── */}602              <Show when={entry().type === "card"}>603                <section class="peach-fields" style="position:relative;z-index:1;">604                  {/* Card subtitle: •••• last4 */}605                  <Show when={entry().last4}>606                    <div class="peach-field-card" style="padding-bottom:4px;">607                      <span class="peach-label">608                        <Show when={entry().network && entry().network !== "unknown"}>609                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>610                        </Show>611                        {entry().cardNickname || `•••• ${entry().last4}`}612                      </span>613                    </div>614                  </Show>615616                  {/* Cardholder name */}617                  <div class="peach-field-card">618                    <span class="peach-label">cardholder name</span>619                    <div class="peach-field-value-row">620                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>621                      <Show when={entry().cardholderName}>622                        <button623                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}624                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}625                          aria-label="Copy cardholder name"626                        >627                          <CopyGlyph /><span class="peach-copy-sheen" />628                        </button>629                      </Show>630                    </div>631                  </div>632633                  {/* Card number */}634                  <div class="peach-field-card">635                    <span class="peach-label">card number</span>636                    <div class="peach-field-value-row">637                      <span class={`peach-field-value peach-card-number`}>638                        <Show when={showCardNumber()} fallback={639                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>640                        }>641                          <span>{entry().cardNumber ?? ""}</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={() => setShowCardNumber(!showCardNumber())}648                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}649                        >650                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>651                            <EyeClosedGlyph />652                          </Show>653                        </button>654                        <Show when={entry().cardNumber}>655                          <button656                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}657                            onClick={(e) => {658                              const cardNum = entry().cardNumber!;659                              if (entry().canary?.webhookUrl) {660                                void sendMessage({661                                  type: "FIRE_CANARY_PING",662                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },663                                });664                              }665                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);666                            }}667                            aria-label="Copy card number"668                          >669                            <CopyGlyph /><span class="peach-copy-sheen" />670                          </button>671                        </Show>672                      </div>673                    </div>674                  </div>675676                  {/* Expiry */}677                  <Show when={entry().expMonth || entry().expYear}>678                    <div class="peach-field-card">679                      <span class="peach-label">expiry</span>680                      <div class="peach-field-value-row">681                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>682                        <button683                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}684                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}685                          aria-label="Copy expiry"686                        >687                          <CopyGlyph /><span class="peach-copy-sheen" />688                        </button>689                      </div>690                    </div>691                  </Show>692693                  {/* CVC */}694                  <Show when={entry().cvc}>695                    <div class="peach-field-card">696                      <span class="peach-label">CVC</span>697                      <div class="peach-field-value-row">698                        <span class="peach-field-value">699                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>700                            <span>{entry().cvc}</span>701                          </Show>702                        </span>703                        <div class="peach-field-actions">704                          <button705                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"706                            onClick={() => setShowCardCvc(!showCardCvc())}707                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}708                          >709                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>710                              <EyeClosedGlyph />711                            </Show>712                          </button>713                          <button714                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}715                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}716                            aria-label="Copy CVC"717                          >718                            <CopyGlyph /><span class="peach-copy-sheen" />719                          </button>720                        </div>721                      </div>722                    </div>723                  </Show>724                </section>725              </Show>726727              {/* ── Login fields ─────────────────────────────────── */}728              <Show when={entry().type !== "note" && entry().type !== "card" && !isPasskeyOnly(entry())}>729                <section class="peach-fields" style="position:relative;z-index:1;">730731                  {/* Username */}732                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>733                    <span class="peach-label">username</span>734                    <Show735                      when={editingField() === "username"}736                      fallback={737                        <div class="peach-field-value-row">738                          <span739                            class="peach-field-value peach-inline-clickable"740                            title="Click to edit"741                            onClick={() => startEdit("username", entry().username ?? "")}742                          >743                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}744                          </span>745                          <Show when={entry().username}>746                            <button747                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}748                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}749                              aria-label="Copy username"750                            >751                              <CopyGlyph /><span class="peach-copy-sheen" />752                            </button>753                          </Show>754                        </div>755                      }756                    >757                      <div758                        class="peach-inline-field-wrap"759                        onFocusOut={(event) => handleEditorFocusOut("username", event)}760                      >761                        <input762                          class="peach-inline-input"763                          value={draft()}764                          onInput={(e) => setDraft(e.currentTarget.value)}765                          onKeyDown={onEditKeyDown}766                          ref={autoFocus}767                          disabled={saving()}768                          autocomplete="off"769                        />770                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />771                      </div>772                    </Show>773                  </div>774775                  {/* Password */}776                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>777                    <span class="peach-label">password</span>778                    <Show779                      when={editingField() === "password"}780                      fallback={781                        <div class="peach-field-value-row">782                          <span783                            class="peach-password-value"784                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}785                            style="cursor:pointer;"786                            title="Click to edit password"787                            onClick={() => startEdit("password", entry().password ?? "")}788                          >789                            <Show790                              when={showPassword()}791                              fallback={<span class="peach-password-mask" aria-label="password hidden">••••••••••••••</span>}792                            >793                              <span class="peach-password-text">{entry().password ?? ""}</span>794                            </Show>795                          </span>796                          <div class="peach-field-actions">797                            <button798                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"799                              onClick={() => setShowPassword(!showPassword())}800                              aria-label="Reveal password"801                            >802                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>803                                <EyeClosedGlyph />804                              </Show>805                            </button>806                            <button807                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}808                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}809                              aria-label="Copy password"810                            >811                              <CopyGlyph /><span class="peach-copy-sheen" />812                            </button>813                          </div>814                        </div>815                      }816                    >817                      {/* ── Edit mode: type / paste / generate ── */}818                      <div819                        class="peach-inline-field-wrap"820                        onFocusOut={(event) => handleEditorFocusOut("password", event)}821                      >822                        <div class="peach-password-edit-row">823                          <input824                            class="peach-inline-input"825                            type={showPassword() ? "text" : "password"}826                            placeholder="paste or type a password"827                            value={draft()}828                            onInput={(e) => setDraft(e.currentTarget.value)}829                            onKeyDown={(e) => {830                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }831                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }832                            }}833                            ref={autoFocus}834                            disabled={saving()}835                            autocomplete="new-password"836                          />837                          <button838                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"839                            type="button"840                            onClick={() => setShowPassword(!showPassword())}841                            aria-label={showPassword() ? "Hide" : "Show"}842                          >843                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>844                              <EyeClosedGlyph />845                            </Show>846                          </button>847                        </div>848                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />849                        <button850                          class="peach-generate-toggle-link"851                          type="button"852                          onClick={() => setShowGenerator(!showGenerator())}853                        >854                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">855                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />856                            <path d="M9.5 2.5H11V1" />857                          </svg>858                          {showGenerator() ? "hide generator" : "generate"}859                        </button>860                        <Show when={showGenerator()}>861                          <GeneratorDrawer862                            open={true}863                            compact={true}864                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}865                          />866                        </Show>867                      </div>868                    </Show>869                  </div>870871                </section>872              </Show>873874              {/* ── Notes (on login entries) ──────────────────────── */}875              <Show when={entry().type !== "note" && entry().type !== "card"}>876                <Show877                  when={entry().notes || editingField() === "notes"}878                  fallback={879                    <button880                      class="peach-add-field-link peach-entry-add-link"881                      onClick={() => startEdit("notes", "")}882                    >883                      + notes884                    </button>885                  }886                >887                  <div style="position:relative;z-index:1;margin-top:6px;">888                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>889                    <Show890                      when={editingField() === "notes"}891                      fallback={892                        <div893                          class="peach-note-content peach-inline-clickable"894                          style="cursor:text;white-space:pre-wrap;"895                          onClick={() => startEdit("notes", entry().notes ?? "")}896                        >897                          {entry().notes}898                        </div>899                      }900                    >901                      <div902                        class="peach-inline-field-wrap"903                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}904                      >905                        <textarea906                          class="peach-inline-textarea"907                          value={draft()}908                          onInput={(e) => setDraft(e.currentTarget.value)}909                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}910                          ref={(el) => requestAnimationFrame(() => el?.focus())}911                          rows={4}912                          disabled={saving()}913                        />914                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />915                      </div>916                    </Show>917                  </div>918                </Show>919              </Show>920921              {/* ── Tags editor / add-tags link (between notes and TOTP) ─── */}922              <Show923                when={editingTags()}924                fallback={925                  <Show when={!entry().tags || entry().tags!.length === 0}>926                    <button927                      class="peach-add-field-link peach-entry-add-link"928                      onClick={startEditTags}929                    >930                      + tags931                    </button>932                  </Show>933                }934              >935                <div936                  class="peach-inline-tag-editor"937                  onFocusOut={(e) => {938                    const next = e.relatedTarget;939                    if (next instanceof Node && e.currentTarget.contains(next)) return;940                    void saveTags();941                  }}942                >943                  <span class="peach-label">tags</span>944                  <div class="peach-tag-input-wrap">945                    <For each={tagsDraft()}>946                      {(tag) => (947                        <span class="peach-tag-chip">948                          {tag}949                          <button950                            class="peach-tag-chip-remove"951                            onMouseDown={(e) => e.preventDefault()}952                            onClick={() => setTagsDraft(tagsDraft().filter(t => t !== tag))}953                            aria-label={`Remove ${tag}`}954                          >×</button>955                        </span>956                      )}957                    </For>958                    <input959                      class="peach-tag-input"960                      placeholder={tagsDraft().length === 0 ? "type and press Enter…" : ""}961                      value={tagInput()}962                      onInput={(e) => setTagInput(e.currentTarget.value)}963                      onKeyDown={(e) => {964                        if (e.key === "Enter" || e.key === ",") {965                          e.preventDefault();966                          addTag(tagInput());967                        } else if (e.key === "Backspace" && !tagInput() && tagsDraft().length > 0) {968                          setTagsDraft(tagsDraft().slice(0, -1));969                        } else if (e.key === "Escape") {970                          e.preventDefault();971                          setEditingTags(false);972                          setTagInput("");973                        }974                      }}975                      ref={(el) => requestAnimationFrame(() => el?.focus())}976                      style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"977                    />978                  </div>979                </div>980              </Show>981982              {/* ── TOTP ─────────────────────────────────────────── */}983              <Show when={entry().type === "login" && !isPasskeyOnly(entry())}>984                <Show985                  when={totpState() || editingField() === "totp"}986                  fallback={987                    <button988                      class="peach-add-field-link peach-entry-add-link"989                      onClick={() => startEdit("totp", "")}990                    >991                      + 2FA992                    </button>993                  }994                >995                  <section class="peach-totp-band">996                    <Show997                      when={editingField() === "totp"}998                      fallback={999                        <Show when={totpState()}>1000                          {(state) => (1001                            <>1002                              <div1003                                class="peach-totp-main"1004                                onClick={() => void copyField(state().code.replace(" ", ""), "totp")}1005                                onDblClick={() => startEdit("totp", entry().totp ?? "")}1006                                title="Click to copy · double-click to edit"1007                              >1008                                <span class="peach-label">2-factor</span>1009                                <div class="peach-totp-code-row">1010                                  <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}${copied() === "totp" ? " is-copied" : ""}`}>1011                                    <span class="peach-totp-code-text">1012                                      {state().code.slice(0, 3)} {state().code.slice(3)}1013                                    </span>1014                                  </p>1015                                </div>1016                              </div>1017                              <div class="peach-totp-meta">1018                                <span class="peach-label">expires</span>1019                                <div class="peach-totp-countdown">{state().remaining}s</div>1020                                <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">1021                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />1022                                  <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"1023                                    stroke-dasharray="106.8"1024                                    stroke-dashoffset={(1 - state().remaining / 30) * 106.8}1025                                  />1026                                </svg>1027                              </div>1028                            </>1029                          )}1030                        </Show>1031                      }1032                    >1033                      <div1034                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"1035                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}1036                      >1037                        <span class="peach-label">2-factor</span>1038                        <input1039                          class="peach-inline-input"1040                          value={draft()}1041                          onInput={(e) => setDraft(e.currentTarget.value)}1042                          onKeyDown={onEditKeyDown}1043                          ref={autoFocus}1044                          disabled={saving()}1045                          placeholder="otpauth://... or base32 secret"1046                          autocomplete="off"1047                        />1048                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />1049                      </div>1050                    </Show>1051                  </section>1052                </Show>1053              </Show>10541055              {/* ── Passkey ──────────────────────────────────────── */}1056              <Show when={entry().passkey}>1057                {(passkey) => {1058                  const currentBrowser = detectBrowser();1059                  const crossBrowser =1060                    passkey().browser &&1061                    currentBrowser !== "unknown" &&1062                    passkey().browser !== currentBrowser;1063                  const algorithmLabel =1064                    passkey().algorithm === -8 ? "EdDSA (Ed25519)" : "ES256 (P-256)";1065                  const formatDate = (ts?: number) =>1066                    ts ? new Date(ts).toLocaleDateString() : "Never";1067                  return (1068                    <section1069                      class="peach-fields peach-passkey-section"1070                      style="position:relative;z-index:1;margin-top:6px;"1071                    >1072                      <div class="peach-field-card">1073                        <span class="peach-label">passkey · {passkey().rpId}</span>1074                        <div class="peach-field-value-row">1075                          <span class="peach-field-value">{passkey().userName}</span>1076                          <span class="peach-passkey-badge">passkey</span>1077                        </div>1078                      </div>10791080                      <div class="peach-field-card">1081                        <span class="peach-label">algorithm</span>1082                        <div class="peach-field-value-row">1083                          <span class="peach-field-value">{algorithmLabel}</span>1084                        </div>1085                      </div>10861087                      <div class="peach-field-card">1088                        <span class="peach-label">activity</span>1089                        <div class="peach-field-value-row peach-passkey-activity">1090                          <span class="peach-field-value">1091                            used {passkey().signCount} time{passkey().signCount === 1 ? "" : "s"}1092                          </span>1093                          <span class="peach-passkey-meta">1094                            created {formatDate(passkey().createdAt)}1095                          </span>1096                          <span class="peach-passkey-meta">1097                            last used {formatDate(passkey().lastUsedAt)}1098                          </span>1099                        </div>1100                      </div>11011102                      <Show when={crossBrowser}>1103                        <div class="peach-info-notice peach-info-notice--warn">1104                          {`This passkey was created on ${1105                            passkey().browser === "chrome" ? "Chrome" : "Firefox"1106                          }. To use it on ${1107                            currentBrowser === "chrome" ? "Chrome" : "Firefox"1108                          }, re-register it on this device.`}1109                        </div>1110                      </Show>11111112                      <div class="peach-info-notice">1113                        {passkey().browser === "firefox"1114                          ? "This passkey is held by your platform authenticator. Peach stores only the metadata."1115                          : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}1116                      </div>11171118                      <button1119                        class="peach-add-field-link peach-entry-add-link"1120                        style="color:rgba(220,38,38,0.7);"1121                        onClick={async () => {1122                          const id = entry().id;1123                          await sendMessage({1124                            type: "UPDATE_ENTRY",1125                            payload: { id, patch: { passkey: undefined } },1126                          });1127                          await props.onFieldSaved();1128                        }}1129                      >1130                        − remove passkey1131                      </button>1132                    </section>1133                  );1134                }}1135              </Show>11361137              {/* ── Bottom spacer ─────────────────────────────────── */}1138              <div style="height:4px;" />1139            </>1140          )}1141        </Show>1142      </div>11431144    </>1145  );1146}11471148// ── Sub-components ────────────────────────────────────────────────────11491150function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {1151  return (1152    <div class="peach-inline-actions">1153      <button1154        class="peach-inline-save-btn"1155        onClick={props.onSave}1156        disabled={props.saving}1157        aria-label="Save"1158        title="Save (Enter)"1159      >1160        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1161          <path d="M1 4.5L4 7.5L10 1.5" />1162        </svg>1163      </button>1164      <button1165        class="peach-inline-cancel-btn"1166        onClick={props.onCancel}1167        disabled={props.saving}1168        aria-label="Cancel"1169        title="Cancel (Esc)"1170      >1171        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">1172          <path d="M1 1L8 8M8 1L1 8" />1173        </svg>1174      </button>1175    </div>1176  );1177}11781179function EmptyVaultState() {1180  return (1181    <>1182      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">1183        <div class="peach-entry-name">1184          <span class="peach-entry-name-text">1185            <span class="peach-entry-name-char" style="--char-index:0">e</span>1186            <span class="peach-entry-name-char" style="--char-index:1">m</span>1187            <span class="peach-entry-name-char" style="--char-index:2">p</span>1188            <span class="peach-entry-name-char" style="--char-index:3">t</span>1189            <span class="peach-entry-name-char" style="--char-index:4">y</span>1190          </span>1191        </div>1192      </div>1193      <div class="peach-empty-copy">1194        <span class="peach-label">vault</span>1195        <p>Your vault is ready for its first entry.</p>1196      </div>1197    </>1198  );1199}12001201function CopyGlyph() {1202  return (1203    <span class="peach-copy-icon">1204      <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">1205        <rect x="4" y="4" width="8" height="9" rx="1.5" />1206        <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" />1207      </svg>1208    </span>1209  );1210}12111212function EyeClosedGlyph() {1213  return (1214    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1215      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />1216      <path d="M3.5 5.5L2.5 7.5" />1217      <path d="M7 6.5V8.5" />1218      <path d="M10.5 5.5L11.5 7.5" />1219    </svg>1220  );1221}12221223function EyeOpenGlyph() {1224  return (1225    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">1226      <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" />1227      <circle cx="7" cy="5" r="1.75" />1228    </svg>1229  );1230}12311232function ChevronGlyph(props: { open: boolean }) {1233  return (1234    <svg1235      width="8"1236      height="8"1237      viewBox="0 0 8 8"1238      fill="none"1239      stroke="currentColor"1240      stroke-width="1.3"1241      stroke-linecap="round"1242      stroke-linejoin="round"1243      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}1244    >1245      <path d="M2 1L6 4L2 7" />1246    </svg>1247  );1248}12491250function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {1251  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));1252  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");1253  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");1254  const [testResult, setTestResult] = createSignal<string | null>(null);1255  const [testing, setTesting] = createSignal(false);12561257  async function save() {1258    const url = webhookUrl().trim();1259    const nextLabel = label().trim() || undefined;1260    const canary = enabled() && url1261      ? {1262          webhookUrl: url,1263          label: nextLabel,1264          lastPinged: props.entry.canary?.lastPinged,1265          pingCount: props.entry.canary?.pingCount,1266        }1267      : undefined;1268    await sendMessage({1269      type: "UPDATE_ENTRY",1270      payload: { id: props.entry.id, patch: { canary } },1271    });1272    await props.onSaved();1273  }12741275  async function testPing() {1276    const url = webhookUrl().trim();1277    if (!url) return;1278    setTesting(true);1279    setTestResult(null);1280    const res = await sendMessage({1281      type: "FIRE_CANARY_PING",1282      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1283    }) as { ok: boolean; error?: string };1284    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1285    setTesting(false);1286  }12871288  return (1289    <div class="peach-canary-section">1290      <div class="peach-field-value-row">1291        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1292        <button1293          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1294          onClick={() => { setEnabled(!enabled()); void save(); }}1295          aria-label="Toggle canary"1296        >1297          <span class="peach-toggle-knob" />1298        </button>1299      </div>1300      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1301        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1302      </p>1303      <Show when={enabled()}>1304        <div class="peach-inline-field-wrap" style="margin-top:8px;">1305          <input1306            class="peach-inline-input"1307            placeholder="https://hooks.zapier.com/..."1308            value={webhookUrl()}1309            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1310            onBlur={() => void save()}1311          />1312          <input1313            class="peach-inline-input"1314            placeholder="label (optional, e.g. AWS decoy)"1315            value={label()}1316            onInput={(e) => setLabel(e.currentTarget.value)}1317            onBlur={() => void save()}1318          />1319          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1320            <button1321              class="peach-btn peach-btn--ghost peach-btn--sm"1322              onClick={() => void testPing()}1323              disabled={testing() || !webhookUrl().trim()}1324            >1325              {testing() ? "sending..." : "test ping"}1326            </button>1327            <Show when={testResult()}>1328              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1329            </Show>1330          </div>1331          <Show when={props.entry.canary?.lastPinged}>1332            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1333              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1334              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1335            </p>1336          </Show>1337        </div>1338      </Show>1339    </div>1340  );1341}