Entry.tsx

46.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 { GeneratorDrawer } from "../components/GeneratorDrawer";8import { NoteEditorInline, Toolbar } from "./NoteEditor";910type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1112interface EntryProps {13  entry: VaultEntry | null;14  onDelete: () => void;15  /** Called after any inline save so App can reload entries */16  onFieldSaved: () => void | Promise<void>;17  onEscape?: () => void;18}1920export function EntryView(props: EntryProps) {21  const [editingField, setEditingField] = createSignal<EditableField>(null);22  const [draft, setDraft] = createSignal("");23  const [saving, setSaving] = createSignal(false);24  const [showPassword, setShowPassword] = createSignal(false);25  const [showGenerator, setShowGenerator] = createSignal(false);26  const [copied, setCopied] = createSignal<string | null>(null);27  const [showCardNumber, setShowCardNumber] = createSignal(false);28  const [showCardCvc, setShowCardCvc] = createSignal(false);29  const [totpTick, setTotpTick] = createSignal(0);30  const [confirmDelete, setConfirmDelete] = createSignal(false);31  const [totpHot, setTotpHot] = createSignal(false);32  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);33  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);34  let lastSavedNote: string | null = null;3536  function noteExec(cmd: string, val?: string) {37    document.execCommand(cmd, false, val);38    noteEditorEl()?.focus();39  }4041  async function saveNoteInline(html: string) {42    const entry = props.entry;43    if (!entry) return;44    const currentStored = entry.notes ?? "";45    if (html === currentStored) return;46    if (lastSavedNote !== null && html === lastSavedNote) return;47    lastSavedNote = html;48    setSaving(true);49    try {50      await sendMessage({51        type: "UPDATE_ENTRY",52        payload: { id: entry.id, patch: { notes: html } },53      });54      triggerRing();55      await props.onFieldSaved();56    } catch {57      lastSavedNote = null;58    } finally {59      setSaving(false);60    }61  }6263  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);64  onCleanup(() => window.clearInterval(interval));6566  const displayDomain = createMemo(() =>67    props.entry?.url ? getDisplayDomain(props.entry.url) : ""68  );6970  createEffect(() => {71    const domain = displayDomain();72    setFaviconSrc(domain ? faviconUrl(domain) : null);73  });7475  const totpState = createMemo(() => {76    void totpTick();77    if (!props.entry?.totp) return null;78    return generateTOTP(props.entry.totp);79  });8081  let prevTotpCode: string | undefined;82  createEffect(() => {83    const state = totpState();84    if (!state) { prevTotpCode = undefined; return; }85    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {86      setTotpHot(true);87      window.setTimeout(() => setTotpHot(false), 3000);88    }89    prevTotpCode = state.code;90  });9192  const totpProgress = createMemo(() => {93    const state = totpState();94    if (!state) return 0;95    return ((state.remaining - 1) / 29) * 100;96  });9798  createEffect(() => {99    const shell = document.getElementById("hd-shell");100    if (!shell) return;101102    const state = totpState();103    if (!state) {104      delete shell.dataset.totpActive;105      shell.style.removeProperty("--peach-totp-progress");106      return;107    }108109    shell.dataset.totpActive = "true";110    shell.style.setProperty(111      "--peach-totp-progress",112      `${Math.max(0, totpProgress())}%`113    );114115    onCleanup(() => {116      delete shell.dataset.totpActive;117      shell.style.removeProperty("--peach-totp-progress");118    });119  });120121  const titleSizeClass = createMemo(() => {122    const length = props.entry?.title.length ?? 0;123    if (length >= 28) return "peach-entry-name--xxl";124    if (length >= 20) return "peach-entry-name--xl";125    if (length >= 14) return "peach-entry-name--lg";126    return "";127  });128  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);129130  // ── Inline editing ────────────────────────────────────────────────131132  function startEdit(field: EditableField, value: string) {133    setEditingField(field);134    setDraft(value);135    setShowGenerator(false);136    setConfirmDelete(false);137  }138139  async function saveField() {140    const field = editingField();141    const entry = props.entry;142    if (!field || !entry) return;143    // Don't trim passwords — spaces may be intentional144    let value = field === "password" ? draft() : draft().trim();145    if (field === "totp" && value.startsWith("otpauth://")) {146      const parsed = parseOtpauthURI(value);147      value = parsed?.secret ?? value;148    }149    if (field === "password" && entry.type !== "note" && !value) {150      return;151    }152    setSaving(true);153    try {154      await sendMessage({155        type: "UPDATE_ENTRY",156        payload: { id: entry.id, patch: { [field]: value } },157      });158      triggerRing();159      setEditingField(null);160      setShowGenerator(false);161      await props.onFieldSaved();162    } catch {163      // silent — leave editing open164    } finally {165      setSaving(false);166    }167  }168169  function cancelEdit() {170    setEditingField(null);171    setDraft("");172    setShowGenerator(false);173  }174175  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {176    switch (field) {177      case "title":178        return entry.title;179      case "url":180        return entry.url ?? "";181      case "username":182        return entry.username ?? "";183      case "password":184        return entry.password ?? "";185      case "notes":186        return entry.notes ?? "";187      case "totp":188        return entry.totp ?? "";189    }190  }191192  function handleEditorFocusOut(193    field: Exclude<EditableField, null>,194    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }195  ) {196    const nextTarget = event.relatedTarget;197    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;198199    const entry = props.entry;200    if (!entry) return;201202    const currentValue = fieldValue(entry, field);203    const nextValue = field === "password" ? draft() : draft().trim();204    if (nextValue === currentValue) {205      cancelEdit();206    }207  }208209  async function savePassword(password: string) {210    const entry = props.entry;211    if (!entry) return;212    setSaving(true);213    try {214      await sendMessage({215        type: "UPDATE_ENTRY",216        payload: { id: entry.id, patch: { password } },217      });218      triggerRing();219      setShowGenerator(false);220      await props.onFieldSaved();221    } catch {222      // silent223    } finally {224      setSaving(false);225    }226  }227228  // ── Copy ─────────────────────────────────────────────────────────229230  async function copyField(text: string, label: string, button?: HTMLButtonElement) {231    const response = await sendMessage({ type: "COPY", payload: { text, label } });232    if ((response as { ok: boolean }).ok) {233      const entry = props.entry;234      if (entry?.canary?.webhookUrl) {235        const trigger =236          label === "password"237            ? "password_copied"238            : label === "username"239              ? "username_copied"240              : undefined;241        if (trigger) {242          void sendMessage({243            type: "FIRE_CANARY_PING",244            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },245          });246        }247      }248      triggerRing();249      triggerPipPulse();250      setCopied(null);251      requestAnimationFrame(() => setCopied(label));252      if (button) {253        button.classList.remove("is-copied", "is-retreat");254        void button.offsetWidth;255        const drain = document.createElement("span");256        drain.className = "peach-copy-drain";257        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";258        drain.appendChild(bar);259        button.appendChild(drain);260        window.setTimeout(() => {261          button.classList.remove("is-copied");262          button.classList.add("is-retreat");263          drain.remove();264          window.setTimeout(() => button.classList.remove("is-retreat"), 160);265        }, 1800);266      }267      window.setTimeout(() => setCopied(null), 1800);268    }269  }270271  // ── Keyboard shortcuts ────────────────────────────────────────────272273  createEffect(() => {274    if (!props.entry) return;275    const handler = (e: KeyboardEvent) => {276      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;277      if (inInput) return;278      switch (e.key) {279        case "c": e.preventDefault();280          if (props.entry?.password) void copyField(props.entry.password, "password"); break;281        case "u": e.preventDefault();282          if (props.entry?.username) void copyField(props.entry.username, "username"); break;283        case "Escape": e.preventDefault(); props.onEscape?.(); break;284      }285    };286    window.addEventListener("keydown", handler);287    onCleanup(() => window.removeEventListener("keydown", handler));288  });289290  // ── Input ref focus helper ────────────────────────────────────────291292  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {293    requestAnimationFrame(() => { el.focus(); el.select?.(); });294  }295296  function onEditKeyDown(e: KeyboardEvent) {297    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {298      e.preventDefault();299      void saveField();300    }301    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }302  }303304  return (305    <>306      <div307        class="peach-ghost"308        aria-hidden="true"309        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;"310      >311        {props.entry?.title || "empty"}312      </div>313314      <div class="peach-stage" style="position:relative;">315        <Show when={props.entry} fallback={<EmptyVaultState />}>316          {(entry) => (317            <>318              {/* ── Breach strip ─────────────────────────────────── */}319              <Show when={entry().type !== "note" && entry().type !== "card" && (entry().breach ?? 0) > 0}>320                <div class="peach-breach-badge">321                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">322                    <path d="M6 1L11 10H1L6 1z" />323                    <line x1="6" y1="5" x2="6" y2="7.5" />324                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />325                  </svg>326                  <span style="flex:1">found in a known data breach</span>327                  <button328                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"329                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}330                  >331                    Change password332                  </button>333                </div>334              </Show>335336              {/* ── Title + trash ─────────────────────────────────── */}337              <div class="peach-entry-header">338                <div class="peach-entry-header-name">339                  <Show340                    when={editingField() === "title"}341                    fallback={342                      <button343                        class="peach-entry-name-button"344                        title="Click to edit title"345                        onClick={() => startEdit("title", entry().title)}346                      >347                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>348                          <span class="peach-entry-name-text">349                            <Show350                              when={useAnimatedTitle()}351                              fallback={entry().title}352                            >353                              <For each={entry().title.split("")}>354                                {(ch, i) => (355                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>356                                    {ch === " " ? "\u00A0" : ch}357                                  </span>358                                )}359                              </For>360                            </Show>361                          </span>362                        </h1>363                      </button>364                    }365                  >366                    <div367                      class="peach-inline-title-wrap"368                      onFocusOut={(event) => handleEditorFocusOut("title", event)}369                    >370                      <input371                        class="peach-inline-title-input"372                        value={draft()}373                        onInput={(e) => setDraft(e.currentTarget.value)}374                        onKeyDown={onEditKeyDown}375                        ref={autoFocus}376                        disabled={saving()}377                      />378                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />379                    </div>380                  </Show>381                </div>382383                {/* Subtle trash icon — hidden while editing title */}384                <Show when={editingField() !== "title"}>385                  <Show386                    when={confirmDelete()}387                    fallback={388                      <button389                        class="peach-entry-trash-btn"390                        onClick={() => setConfirmDelete(true)}391                        aria-label="Move to trash"392                        title="Delete entry"393                      >394                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">395                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />396                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />397                        </svg>398                      </button>399                    }400                  >401                    <div class="peach-entry-trash-confirm">402                      <span class="peach-entry-trash-confirm-label">trash?</span>403                      <button404                        class="peach-btn peach-btn--sm peach-btn--danger"405                        style="font-size:10px;padding:2px 8px;min-height:22px;"406                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}407                      >408                        yes409                      </button>410                      <button411                        class="peach-btn peach-btn--ghost peach-btn--sm"412                        style="font-size:10px;padding:2px 8px;min-height:22px;"413                        onClick={() => setConfirmDelete(false)}414                      >415                        no416                      </button>417                    </div>418                  </Show>419                </Show>420              </div>421422              {/* ── Domain / URL ─────────────────────────────────── */}423              <Show when={entry().type !== "note" && entry().type !== "card"}>424                <div class="peach-domain" style="position:relative;z-index:1;">425                  <Show426                    when={editingField() === "url"}427                    fallback={428                      <div429                        class="peach-domain-row peach-inline-clickable"430                        title="Click to edit URL"431                        onClick={() => startEdit("url", entry().url ?? "")}432                      >433                        <Show when={faviconSrc()}>434                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />435                        </Show>436                        <span class="peach-domain-value">437                          {displayDomain() || (entry().url ? entry().url : "add a website url")}438                        </span>439                        <span class="peach-inline-edit-hint">edit</span>440                      </div>441                    }442                  >443                    <div444                      class="peach-inline-field-wrap"445                      onFocusOut={(event) => handleEditorFocusOut("url", event)}446                    >447                      <input448                        class="peach-inline-input"449                        type="url"450                        placeholder="https://"451                        value={draft()}452                        onInput={(e) => setDraft(e.currentTarget.value)}453                        onKeyDown={onEditKeyDown}454                        ref={autoFocus}455                        disabled={saving()}456                      />457                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />458                    </div>459                  </Show>460                </div>461              </Show>462463              {/* ── Tags ─────────────────────────────────────────── */}464              <Show when={entry().tags && entry().tags!.length > 0}>465                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">466                  <For each={entry().tags!}>467                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}468                  </For>469                </div>470              </Show>471472              {/* ── Note toolbar + inline editor ─────────────────── */}473              <Show when={entry().type === "note"}>474                <div class="peach-entry-note-toolbar-row">475                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />476                </div>477                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">478                  <NoteEditorInline479                    entryKey={entry().id}480                    initialContent={entry().notes ?? ""}481                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}482                    onBlur={saveNoteInline}483                    autoFocus={false}484                  />485                </section>486              </Show>487488              {/* ── Card detail view ─────────────────────────────── */}489              <Show when={entry().type === "card"}>490                <section class="peach-fields" style="position:relative;z-index:1;">491                  {/* Card subtitle: •••• last4 */}492                  <Show when={entry().last4}>493                    <div class="peach-field-card" style="padding-bottom:4px;">494                      <span class="peach-label">495                        <Show when={entry().network && entry().network !== "unknown"}>496                          <span class="peach-card-network-badge" style="margin-left:0;margin-right:6px;">{entry().network}</span>497                        </Show>498                        {entry().cardNickname || `•••• ${entry().last4}`}499                      </span>500                    </div>501                  </Show>502503                  {/* Cardholder name */}504                  <div class="peach-field-card">505                    <span class="peach-label">cardholder name</span>506                    <div class="peach-field-value-row">507                      <span class="peach-field-value">{entry().cardholderName || "—"}</span>508                      <Show when={entry().cardholderName}>509                        <button510                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardholderName" ? "is-copied" : ""}`}511                          onClick={(e) => void copyField(entry().cardholderName!, "cardholderName", e.currentTarget as HTMLButtonElement)}512                          aria-label="Copy cardholder name"513                        >514                          <CopyGlyph /><span class="peach-copy-sheen" />515                        </button>516                      </Show>517                    </div>518                  </div>519520                  {/* Card number */}521                  <div class="peach-field-card">522                    <span class="peach-label">card number</span>523                    <div class="peach-field-value-row">524                      <span class={`peach-field-value peach-card-number`}>525                        <Show when={showCardNumber()} fallback={526                          <span>{`•••• •••• •••• ${entry().last4 ?? "••••"}`}</span>527                        }>528                          <span>{entry().cardNumber ?? ""}</span>529                        </Show>530                      </span>531                      <div class="peach-field-actions">532                        <button533                          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"534                          onClick={() => setShowCardNumber(!showCardNumber())}535                          aria-label={showCardNumber() ? "Hide card number" : "Reveal card number"}536                        >537                          <Show when={showCardNumber()} fallback={<EyeOpenGlyph />}>538                            <EyeClosedGlyph />539                          </Show>540                        </button>541                        <Show when={entry().cardNumber}>542                          <button543                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cardNumber" ? "is-copied" : ""}`}544                            onClick={(e) => {545                              const cardNum = entry().cardNumber!;546                              if (entry().canary?.webhookUrl) {547                                void sendMessage({548                                  type: "FIRE_CANARY_PING",549                                  payload: { webhookUrl: entry().canary!.webhookUrl, entryTitle: entry().title, trigger: "password_copied" },550                                });551                              }552                              void copyField(cardNum, "cardNumber", e.currentTarget as HTMLButtonElement);553                            }}554                            aria-label="Copy card number"555                          >556                            <CopyGlyph /><span class="peach-copy-sheen" />557                          </button>558                        </Show>559                      </div>560                    </div>561                  </div>562563                  {/* Expiry */}564                  <Show when={entry().expMonth || entry().expYear}>565                    <div class="peach-field-card">566                      <span class="peach-label">expiry</span>567                      <div class="peach-field-value-row">568                        <span class="peach-field-value">{entry().expMonth} / {entry().expYear}</span>569                        <button570                          class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "expiry" ? "is-copied" : ""}`}571                          onClick={(e) => void copyField(`${entry().expMonth}/${entry().expYear}`, "expiry", e.currentTarget as HTMLButtonElement)}572                          aria-label="Copy expiry"573                        >574                          <CopyGlyph /><span class="peach-copy-sheen" />575                        </button>576                      </div>577                    </div>578                  </Show>579580                  {/* CVC */}581                  <Show when={entry().cvc}>582                    <div class="peach-field-card">583                      <span class="peach-label">CVC</span>584                      <div class="peach-field-value-row">585                        <span class="peach-field-value">586                          <Show when={showCardCvc()} fallback={<span class="peach-password-mask">•••</span>}>587                            <span>{entry().cvc}</span>588                          </Show>589                        </span>590                        <div class="peach-field-actions">591                          <button592                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"593                            onClick={() => setShowCardCvc(!showCardCvc())}594                            aria-label={showCardCvc() ? "Hide CVC" : "Show CVC"}595                          >596                            <Show when={showCardCvc()} fallback={<EyeOpenGlyph />}>597                              <EyeClosedGlyph />598                            </Show>599                          </button>600                          <button601                            class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "cvc" ? "is-copied" : ""}`}602                            onClick={(e) => void copyField(entry().cvc!, "cvc", e.currentTarget as HTMLButtonElement)}603                            aria-label="Copy CVC"604                          >605                            <CopyGlyph /><span class="peach-copy-sheen" />606                          </button>607                        </div>608                      </div>609                    </div>610                  </Show>611                </section>612              </Show>613614              {/* ── Login fields ─────────────────────────────────── */}615              <Show when={entry().type !== "note" && entry().type !== "card"}>616                <section class="peach-fields" style="position:relative;z-index:1;">617618                  {/* Username */}619                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>620                    <span class="peach-label">username</span>621                    <Show622                      when={editingField() === "username"}623                      fallback={624                        <div class="peach-field-value-row">625                          <span626                            class="peach-field-value peach-inline-clickable"627                            title="Click to edit"628                            onClick={() => startEdit("username", entry().username ?? "")}629                          >630                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}631                          </span>632                          <Show when={entry().username}>633                            <button634                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}635                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}636                              aria-label="Copy username"637                            >638                              <CopyGlyph /><span class="peach-copy-sheen" />639                            </button>640                          </Show>641                        </div>642                      }643                    >644                      <div645                        class="peach-inline-field-wrap"646                        onFocusOut={(event) => handleEditorFocusOut("username", event)}647                      >648                        <input649                          class="peach-inline-input"650                          value={draft()}651                          onInput={(e) => setDraft(e.currentTarget.value)}652                          onKeyDown={onEditKeyDown}653                          ref={autoFocus}654                          disabled={saving()}655                          autocomplete="off"656                        />657                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />658                      </div>659                    </Show>660                  </div>661662                  {/* Password */}663                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>664                    <span class="peach-label">password</span>665                    <Show666                      when={editingField() === "password"}667                      fallback={668                        <div class="peach-field-value-row">669                          <span670                            class="peach-password-value"671                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}672                            style="cursor:pointer;"673                            title="Click to edit password"674                            onClick={() => startEdit("password", entry().password ?? "")}675                          >676                            <Show677                              when={showPassword()}678                              fallback={<span class="peach-password-mask">...............</span>}679                            >680                              <span class="peach-password-text">{entry().password ?? ""}</span>681                            </Show>682                          </span>683                          <div class="peach-field-actions">684                            <button685                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"686                              onClick={() => setShowPassword(!showPassword())}687                              aria-label="Reveal password"688                            >689                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>690                                <EyeClosedGlyph />691                              </Show>692                            </button>693                            <button694                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}695                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}696                              aria-label="Copy password"697                            >698                              <CopyGlyph /><span class="peach-copy-sheen" />699                            </button>700                          </div>701                        </div>702                      }703                    >704                      {/* ── Edit mode: type / paste / generate ── */}705                      <div706                        class="peach-inline-field-wrap"707                        onFocusOut={(event) => handleEditorFocusOut("password", event)}708                      >709                        <div class="peach-password-edit-row">710                          <input711                            class="peach-inline-input"712                            type={showPassword() ? "text" : "password"}713                            placeholder="paste or type a password"714                            value={draft()}715                            onInput={(e) => setDraft(e.currentTarget.value)}716                            onKeyDown={(e) => {717                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }718                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }719                            }}720                            ref={autoFocus}721                            disabled={saving()}722                            autocomplete="new-password"723                          />724                          <button725                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"726                            type="button"727                            onClick={() => setShowPassword(!showPassword())}728                            aria-label={showPassword() ? "Hide" : "Show"}729                          >730                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>731                              <EyeClosedGlyph />732                            </Show>733                          </button>734                        </div>735                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />736                        <button737                          class="peach-generate-toggle-link"738                          type="button"739                          onClick={() => setShowGenerator(!showGenerator())}740                        >741                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">742                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />743                            <path d="M9.5 2.5H11V1" />744                          </svg>745                          {showGenerator() ? "hide generator" : "generate"}746                        </button>747                        <Show when={showGenerator()}>748                          <GeneratorDrawer749                            open={true}750                            compact={true}751                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}752                          />753                        </Show>754                      </div>755                    </Show>756                  </div>757758                </section>759              </Show>760761              {/* ── Notes (on login entries) ──────────────────────── */}762              <Show when={entry().type !== "note" && entry().type !== "card"}>763                <Show764                  when={entry().notes || editingField() === "notes"}765                  fallback={766                    <button767                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"768                      onClick={() => startEdit("notes", "")}769                    >770                      + add notes771                    </button>772                  }773                >774                  <div style="position:relative;z-index:1;margin-top:6px;">775                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>776                    <Show777                      when={editingField() === "notes"}778                      fallback={779                        <div780                          class="peach-note-content peach-inline-clickable"781                          style="cursor:text;white-space:pre-wrap;"782                          onClick={() => startEdit("notes", entry().notes ?? "")}783                        >784                          {entry().notes}785                        </div>786                      }787                    >788                      <div789                        class="peach-inline-field-wrap"790                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}791                      >792                        <textarea793                          class="peach-inline-textarea"794                          value={draft()}795                          onInput={(e) => setDraft(e.currentTarget.value)}796                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}797                          ref={(el) => requestAnimationFrame(() => el?.focus())}798                          rows={4}799                          disabled={saving()}800                        />801                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />802                      </div>803                    </Show>804                  </div>805                </Show>806              </Show>807808              {/* ── TOTP ─────────────────────────────────────────── */}809              <Show when={totpState()}>810                {(state) => (811                  <section class="peach-totp-band">812                    <Show813                      when={editingField() === "totp"}814                      fallback={815                        <>816                          <div817                            class="peach-totp-main peach-inline-clickable"818                            onClick={() => startEdit("totp", entry().totp ?? "")}819                          >820                            <span class="peach-label">2-factor</span>821                            <div class="peach-totp-code-row">822                              <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>823                                <span class="peach-totp-code-text">824                                  {state().code.slice(0, 3)} {state().code.slice(3)}825                                </span>826                              </p>827                              <button828                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}829                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}830                                aria-label="Copy TOTP code"831                              >832                                <CopyGlyph /><span class="peach-copy-sheen" />833                              </button>834                            </div>835                          </div>836                          <div class="peach-totp-meta">837                            <span class="peach-label">expires</span>838                            <div class="peach-totp-countdown">{state().remaining}s</div>839                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">840                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />841                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"842                                stroke-dasharray="106.8"843                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}844                              />845                            </svg>846                          </div>847                        </>848                      }849                    >850                      <div851                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"852                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}853                      >854                        <span class="peach-label">2-factor</span>855                        <input856                          class="peach-inline-input"857                          value={draft()}858                          onInput={(e) => setDraft(e.currentTarget.value)}859                          onKeyDown={onEditKeyDown}860                          ref={autoFocus}861                          disabled={saving()}862                          placeholder="otpauth://... or base32 secret"863                          autocomplete="off"864                        />865                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />866                      </div>867                    </Show>868                  </section>869                )}870              </Show>871872              {/* ── Bottom spacer ─────────────────────────────────── */}873              <div style="height:4px;" />874            </>875          )}876        </Show>877      </div>878879    </>880  );881}882883// ── Sub-components ────────────────────────────────────────────────────884885function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {886  return (887    <div class="peach-inline-actions">888      <button889        class="peach-inline-save-btn"890        onClick={props.onSave}891        disabled={props.saving}892        aria-label="Save"893        title="Save (Enter)"894      >895        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">896          <path d="M1 4.5L4 7.5L10 1.5" />897        </svg>898      </button>899      <button900        class="peach-inline-cancel-btn"901        onClick={props.onCancel}902        disabled={props.saving}903        aria-label="Cancel"904        title="Cancel (Esc)"905      >906        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">907          <path d="M1 1L8 8M8 1L1 8" />908        </svg>909      </button>910    </div>911  );912}913914function EmptyVaultState() {915  return (916    <>917      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">918        <div class="peach-entry-name">919          <span class="peach-entry-name-text">920            <span class="peach-entry-name-char" style="--char-index:0">e</span>921            <span class="peach-entry-name-char" style="--char-index:1">m</span>922            <span class="peach-entry-name-char" style="--char-index:2">p</span>923            <span class="peach-entry-name-char" style="--char-index:3">t</span>924            <span class="peach-entry-name-char" style="--char-index:4">y</span>925          </span>926        </div>927      </div>928      <div class="peach-empty-copy">929        <span class="peach-label">vault</span>930        <p>Your vault is ready for its first entry.</p>931      </div>932    </>933  );934}935936function CopyGlyph() {937  return (938    <span class="peach-copy-icon">939      <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">940        <rect x="4" y="4" width="8" height="9" rx="1.5" />941        <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" />942      </svg>943    </span>944  );945}946947function EyeClosedGlyph() {948  return (949    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">950      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />951      <path d="M3.5 5.5L2.5 7.5" />952      <path d="M7 6.5V8.5" />953      <path d="M10.5 5.5L11.5 7.5" />954    </svg>955  );956}957958function EyeOpenGlyph() {959  return (960    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">961      <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" />962      <circle cx="7" cy="5" r="1.75" />963    </svg>964  );965}966967function ChevronGlyph(props: { open: boolean }) {968  return (969    <svg970      width="8"971      height="8"972      viewBox="0 0 8 8"973      fill="none"974      stroke="currentColor"975      stroke-width="1.3"976      stroke-linecap="round"977      stroke-linejoin="round"978      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}979    >980      <path d="M2 1L6 4L2 7" />981    </svg>982  );983}984985function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {986  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));987  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");988  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");989  const [testResult, setTestResult] = createSignal<string | null>(null);990  const [testing, setTesting] = createSignal(false);991992  async function save() {993    const url = webhookUrl().trim();994    const nextLabel = label().trim() || undefined;995    const canary = enabled() && url996      ? {997          webhookUrl: url,998          label: nextLabel,999          lastPinged: props.entry.canary?.lastPinged,1000          pingCount: props.entry.canary?.pingCount,1001        }1002      : undefined;1003    await sendMessage({1004      type: "UPDATE_ENTRY",1005      payload: { id: props.entry.id, patch: { canary } },1006    });1007    await props.onSaved();1008  }10091010  async function testPing() {1011    const url = webhookUrl().trim();1012    if (!url) return;1013    setTesting(true);1014    setTestResult(null);1015    const res = await sendMessage({1016      type: "FIRE_CANARY_PING",1017      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },1018    }) as { ok: boolean; error?: string };1019    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);1020    setTesting(false);1021  }10221023  return (1024    <div class="peach-canary-section">1025      <div class="peach-field-value-row">1026        <span class="peach-field-value" style="font-size:12px;">canary alert</span>1027        <button1028          class={`peach-toggle ${enabled() ? "is-on" : ""}`}1029          onClick={() => { setEnabled(!enabled()); void save(); }}1030          aria-label="Toggle canary"1031        >1032          <span class="peach-toggle-knob" />1033        </button>1034      </div>1035      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">1036        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.1037      </p>1038      <Show when={enabled()}>1039        <div class="peach-inline-field-wrap" style="margin-top:8px;">1040          <input1041            class="peach-inline-input"1042            placeholder="https://hooks.zapier.com/..."1043            value={webhookUrl()}1044            onInput={(e) => setWebhookUrl(e.currentTarget.value)}1045            onBlur={() => void save()}1046          />1047          <input1048            class="peach-inline-input"1049            placeholder="label (optional, e.g. AWS decoy)"1050            value={label()}1051            onInput={(e) => setLabel(e.currentTarget.value)}1052            onBlur={() => void save()}1053          />1054          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">1055            <button1056              class="peach-btn peach-btn--ghost peach-btn--sm"1057              onClick={() => void testPing()}1058              disabled={testing() || !webhookUrl().trim()}1059            >1060              {testing() ? "sending..." : "test ping"}1061            </button>1062            <Show when={testResult()}>1063              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>1064            </Show>1065          </div>1066          <Show when={props.entry.canary?.lastPinged}>1067            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">1068              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}1069              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts1070            </p>1071          </Show>1072        </div>1073      </Show>1074    </div>1075  );1076}