Entry.tsx

39.7 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import { Portal } from "solid-js/web";3import type { VaultEntry } from "../../shared/types";4import { getDisplayDomain } from "../../shared/domain";5import { faviconUrl } from "../../shared/favicon";6import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";7import { sendMessage, triggerPipPulse, triggerRing } from "../App";8import { GeneratorDrawer } from "../components/GeneratorDrawer";9import { NoteEditor } from "./NoteEditor";1011type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1213interface EntryProps {14  entry: VaultEntry | null;15  onDelete: () => void;16  /** Called after any inline save so App can reload entries */17  onFieldSaved: () => void | Promise<void>;18  onEscape?: () => void;19}2021export function EntryView(props: EntryProps) {22  const [editingField, setEditingField] = createSignal<EditableField>(null);23  const [draft, setDraft] = createSignal("");24  const [saving, setSaving] = createSignal(false);25  const [showPassword, setShowPassword] = createSignal(false);26  const [showGenerator, setShowGenerator] = createSignal(false);27  const [copied, setCopied] = createSignal<string | null>(null);28  const [totpTick, setTotpTick] = createSignal(0);29  const [confirmDelete, setConfirmDelete] = createSignal(false);30  const [totpHot, setTotpHot] = createSignal(false);31  const [noteEditing, setNoteEditing] = createSignal(false);32  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3334  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);35  onCleanup(() => window.clearInterval(interval));3637  const displayDomain = createMemo(() =>38    props.entry?.url ? getDisplayDomain(props.entry.url) : ""39  );4041  createEffect(() => {42    const domain = displayDomain();43    setFaviconSrc(domain ? faviconUrl(domain) : null);44  });4546  const totpState = createMemo(() => {47    void totpTick();48    if (!props.entry?.totp) return null;49    return generateTOTP(props.entry.totp);50  });5152  let prevTotpCode: string | undefined;53  createEffect(() => {54    const state = totpState();55    if (!state) { prevTotpCode = undefined; return; }56    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {57      setTotpHot(true);58      window.setTimeout(() => setTotpHot(false), 3000);59    }60    prevTotpCode = state.code;61  });6263  const totpProgress = createMemo(() => {64    const state = totpState();65    if (!state) return 0;66    return ((state.remaining - 1) / 29) * 100;67  });6869  createEffect(() => {70    const shell = document.getElementById("hd-shell");71    if (!shell) return;7273    const state = totpState();74    if (!state) {75      delete shell.dataset.totpActive;76      shell.style.removeProperty("--peach-totp-progress");77      return;78    }7980    shell.dataset.totpActive = "true";81    shell.style.setProperty(82      "--peach-totp-progress",83      `${Math.max(0, totpProgress())}%`84    );8586    onCleanup(() => {87      delete shell.dataset.totpActive;88      shell.style.removeProperty("--peach-totp-progress");89    });90  });9192  const titleSizeClass = createMemo(() => {93    const length = props.entry?.title.length ?? 0;94    if (length >= 28) return "peach-entry-name--xxl";95    if (length >= 20) return "peach-entry-name--xl";96    if (length >= 14) return "peach-entry-name--lg";97    return "";98  });99  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);100101  // ── Inline editing ────────────────────────────────────────────────102103  function startEdit(field: EditableField, value: string) {104    setEditingField(field);105    setDraft(value);106    setShowGenerator(false);107    setConfirmDelete(false);108  }109110  async function saveField() {111    const field = editingField();112    const entry = props.entry;113    if (!field || !entry) return;114    // Don't trim passwords — spaces may be intentional115    let value = field === "password" ? draft() : draft().trim();116    if (field === "totp" && value.startsWith("otpauth://")) {117      const parsed = parseOtpauthURI(value);118      value = parsed?.secret ?? value;119    }120    if (field === "password" && entry.type !== "note" && !value) {121      return;122    }123    setSaving(true);124    try {125      await sendMessage({126        type: "UPDATE_ENTRY",127        payload: { id: entry.id, patch: { [field]: value } },128      });129      triggerRing();130      setEditingField(null);131      setShowGenerator(false);132      await props.onFieldSaved();133    } catch {134      // silent — leave editing open135    } finally {136      setSaving(false);137    }138  }139140  function cancelEdit() {141    setEditingField(null);142    setDraft("");143    setShowGenerator(false);144  }145146  async function saveNote(html: string) {147    const entry = props.entry;148    if (!entry) return;149    setSaving(true);150    try {151      await sendMessage({152        type: "UPDATE_ENTRY",153        payload: { id: entry.id, patch: { notes: html } },154      });155      triggerRing();156      setNoteEditing(false);157      await props.onFieldSaved();158    } catch {159      // silent — leave editor open160    } finally {161      setSaving(false);162    }163  }164165  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {166    switch (field) {167      case "title":168        return entry.title;169      case "url":170        return entry.url ?? "";171      case "username":172        return entry.username ?? "";173      case "password":174        return entry.password ?? "";175      case "notes":176        return entry.notes ?? "";177      case "totp":178        return entry.totp ?? "";179    }180  }181182  function handleEditorFocusOut(183    field: Exclude<EditableField, null>,184    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }185  ) {186    const nextTarget = event.relatedTarget;187    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;188189    const entry = props.entry;190    if (!entry) return;191192    const currentValue = fieldValue(entry, field);193    const nextValue = field === "password" ? draft() : draft().trim();194    if (nextValue === currentValue) {195      cancelEdit();196    }197  }198199  async function savePassword(password: string) {200    const entry = props.entry;201    if (!entry) return;202    setSaving(true);203    try {204      await sendMessage({205        type: "UPDATE_ENTRY",206        payload: { id: entry.id, patch: { password } },207      });208      triggerRing();209      setShowGenerator(false);210      await props.onFieldSaved();211    } catch {212      // silent213    } finally {214      setSaving(false);215    }216  }217218  // ── Copy ─────────────────────────────────────────────────────────219220  async function copyField(text: string, label: string, button?: HTMLButtonElement) {221    const response = await sendMessage({ type: "COPY", payload: { text, label } });222    if ((response as { ok: boolean }).ok) {223      const entry = props.entry;224      if (entry?.canary?.webhookUrl) {225        const trigger =226          label === "password"227            ? "password_copied"228            : label === "username"229              ? "username_copied"230              : undefined;231        if (trigger) {232          void sendMessage({233            type: "FIRE_CANARY_PING",234            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },235          });236        }237      }238      triggerRing();239      triggerPipPulse();240      setCopied(null);241      requestAnimationFrame(() => setCopied(label));242      if (button) {243        button.classList.remove("is-copied", "is-retreat");244        void button.offsetWidth;245        const drain = document.createElement("span");246        drain.className = "peach-copy-drain";247        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";248        drain.appendChild(bar);249        button.appendChild(drain);250        window.setTimeout(() => {251          button.classList.remove("is-copied");252          button.classList.add("is-retreat");253          drain.remove();254          window.setTimeout(() => button.classList.remove("is-retreat"), 160);255        }, 1800);256      }257      window.setTimeout(() => setCopied(null), 1800);258    }259  }260261  // ── Keyboard shortcuts ────────────────────────────────────────────262263  createEffect(() => {264    if (!props.entry) return;265    const handler = (e: KeyboardEvent) => {266      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;267      if (inInput) return;268      switch (e.key) {269        case "c": e.preventDefault();270          if (props.entry?.password) void copyField(props.entry.password, "password"); break;271        case "u": e.preventDefault();272          if (props.entry?.username) void copyField(props.entry.username, "username"); break;273        case "Escape": e.preventDefault(); props.onEscape?.(); break;274      }275    };276    window.addEventListener("keydown", handler);277    onCleanup(() => window.removeEventListener("keydown", handler));278  });279280  // ── Input ref focus helper ────────────────────────────────────────281282  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {283    requestAnimationFrame(() => { el.focus(); el.select?.(); });284  }285286  function onEditKeyDown(e: KeyboardEvent) {287    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {288      e.preventDefault();289      void saveField();290    }291    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }292  }293294  return (295    <>296      <div297        class="peach-ghost"298        aria-hidden="true"299        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;"300      >301        {props.entry?.title || "empty"}302      </div>303304      <div class="peach-stage" style="position:relative;">305        <Show when={props.entry} fallback={<EmptyVaultState />}>306          {(entry) => (307            <>308              {/* ── Breach strip ─────────────────────────────────── */}309              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>310                <div class="peach-breach-badge">311                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">312                    <path d="M6 1L11 10H1L6 1z" />313                    <line x1="6" y1="5" x2="6" y2="7.5" />314                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />315                  </svg>316                  <span style="flex:1">found in a known data breach</span>317                  <button318                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"319                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}320                  >321                    Change password322                  </button>323                </div>324              </Show>325326              {/* ── Title + trash ─────────────────────────────────── */}327              <div class="peach-entry-header">328                <div class="peach-entry-header-name">329                  <Show330                    when={editingField() === "title"}331                    fallback={332                      <button333                        class="peach-entry-name-button"334                        title="Click to edit title"335                        onClick={() => startEdit("title", entry().title)}336                      >337                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>338                          <span class="peach-entry-name-text">339                            <Show340                              when={useAnimatedTitle()}341                              fallback={entry().title}342                            >343                              <For each={entry().title.split("")}>344                                {(ch, i) => (345                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>346                                    {ch === " " ? "\u00A0" : ch}347                                  </span>348                                )}349                              </For>350                            </Show>351                          </span>352                        </h1>353                      </button>354                    }355                  >356                    <div357                      class="peach-inline-title-wrap"358                      onFocusOut={(event) => handleEditorFocusOut("title", event)}359                    >360                      <input361                        class="peach-inline-title-input"362                        value={draft()}363                        onInput={(e) => setDraft(e.currentTarget.value)}364                        onKeyDown={onEditKeyDown}365                        ref={autoFocus}366                        disabled={saving()}367                      />368                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />369                    </div>370                  </Show>371                </div>372373                {/* Subtle trash icon — hidden while editing title */}374                <Show when={editingField() !== "title"}>375                  <Show376                    when={confirmDelete()}377                    fallback={378                      <button379                        class="peach-entry-trash-btn"380                        onClick={() => setConfirmDelete(true)}381                        aria-label="Move to trash"382                        title="Delete entry"383                      >384                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">385                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />386                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />387                        </svg>388                      </button>389                    }390                  >391                    <div class="peach-entry-trash-confirm">392                      <span class="peach-entry-trash-confirm-label">trash?</span>393                      <button394                        class="peach-btn peach-btn--sm peach-btn--danger"395                        style="font-size:10px;padding:2px 8px;min-height:22px;"396                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}397                      >398                        yes399                      </button>400                      <button401                        class="peach-btn peach-btn--ghost peach-btn--sm"402                        style="font-size:10px;padding:2px 8px;min-height:22px;"403                        onClick={() => setConfirmDelete(false)}404                      >405                        no406                      </button>407                    </div>408                  </Show>409                </Show>410              </div>411412              {/* ── Domain / URL ─────────────────────────────────── */}413              <Show when={entry().type !== "note"}>414                <div class="peach-domain" style="position:relative;z-index:1;">415                  <Show416                    when={editingField() === "url"}417                    fallback={418                      <div419                        class="peach-domain-row peach-inline-clickable"420                        title="Click to edit URL"421                        onClick={() => startEdit("url", entry().url ?? "")}422                      >423                        <Show when={faviconSrc()}>424                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />425                        </Show>426                        <span class="peach-domain-value">427                          {displayDomain() || (entry().url ? entry().url : "add a website url")}428                        </span>429                        <span class="peach-inline-edit-hint">edit</span>430                      </div>431                    }432                  >433                    <div434                      class="peach-inline-field-wrap"435                      onFocusOut={(event) => handleEditorFocusOut("url", event)}436                    >437                      <input438                        class="peach-inline-input"439                        type="url"440                        placeholder="https://"441                        value={draft()}442                        onInput={(e) => setDraft(e.currentTarget.value)}443                        onKeyDown={onEditKeyDown}444                        ref={autoFocus}445                        disabled={saving()}446                      />447                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />448                    </div>449                  </Show>450                </div>451              </Show>452453              {/* ── Tags ─────────────────────────────────────────── */}454              <Show when={entry().tags && entry().tags!.length > 0}>455                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">456                  <For each={entry().tags!}>457                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}458                  </For>459                </div>460              </Show>461462              {/* ── Note content ─────────────────────────────────── */}463              <Show when={entry().type === "note"}>464                <section class="peach-fields" style="position:relative;z-index:1;">465                  <Show466                    when={entry().notes}467                    fallback={468                      <div469                        class="peach-note-content peach-inline-clickable"470                        style="cursor:text;"471                        onClick={() => setNoteEditing(true)}472                      >473                        <span style="color:var(--peach-t2);font-style:italic;">click to add notes</span>474                      </div>475                    }476                  >477                    <div478                      class="peach-note-content peach-inline-clickable"479                      innerHTML={entry().notes}480                      onClick={() => setNoteEditing(true)}481                    />482                  </Show>483                </section>484              </Show>485486              {/* ── Login fields ─────────────────────────────────── */}487              <Show when={entry().type !== "note"}>488                <section class="peach-fields" style="position:relative;z-index:1;">489490                  {/* Username */}491                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>492                    <span class="peach-label">username</span>493                    <Show494                      when={editingField() === "username"}495                      fallback={496                        <div class="peach-field-value-row">497                          <span498                            class="peach-field-value peach-inline-clickable"499                            title="Click to edit"500                            onClick={() => startEdit("username", entry().username ?? "")}501                          >502                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}503                          </span>504                          <Show when={entry().username}>505                            <button506                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}507                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}508                              aria-label="Copy username"509                            >510                              <CopyGlyph /><span class="peach-copy-sheen" />511                            </button>512                          </Show>513                        </div>514                      }515                    >516                      <div517                        class="peach-inline-field-wrap"518                        onFocusOut={(event) => handleEditorFocusOut("username", event)}519                      >520                        <input521                          class="peach-inline-input"522                          value={draft()}523                          onInput={(e) => setDraft(e.currentTarget.value)}524                          onKeyDown={onEditKeyDown}525                          ref={autoFocus}526                          disabled={saving()}527                          autocomplete="off"528                        />529                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />530                      </div>531                    </Show>532                  </div>533534                  {/* Password */}535                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>536                    <span class="peach-label">password</span>537                    <Show538                      when={editingField() === "password"}539                      fallback={540                        <div class="peach-field-value-row">541                          <span542                            class="peach-password-value"543                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}544                            style="cursor:pointer;"545                            title="Click to edit password"546                            onClick={() => startEdit("password", entry().password ?? "")}547                          >548                            <Show549                              when={showPassword()}550                              fallback={<span class="peach-password-mask">...............</span>}551                            >552                              <span class="peach-password-text">{entry().password ?? ""}</span>553                            </Show>554                          </span>555                          <div class="peach-field-actions">556                            <button557                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"558                              onClick={() => setShowPassword(!showPassword())}559                              aria-label="Reveal password"560                            >561                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>562                                <EyeClosedGlyph />563                              </Show>564                            </button>565                            <button566                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}567                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}568                              aria-label="Copy password"569                            >570                              <CopyGlyph /><span class="peach-copy-sheen" />571                            </button>572                          </div>573                        </div>574                      }575                    >576                      {/* ── Edit mode: type / paste / generate ── */}577                      <div578                        class="peach-inline-field-wrap"579                        onFocusOut={(event) => handleEditorFocusOut("password", event)}580                      >581                        <div class="peach-password-edit-row">582                          <input583                            class="peach-inline-input"584                            type={showPassword() ? "text" : "password"}585                            placeholder="paste or type a password"586                            value={draft()}587                            onInput={(e) => setDraft(e.currentTarget.value)}588                            onKeyDown={(e) => {589                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }590                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }591                            }}592                            ref={autoFocus}593                            disabled={saving()}594                            autocomplete="new-password"595                          />596                          <button597                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"598                            type="button"599                            onClick={() => setShowPassword(!showPassword())}600                            aria-label={showPassword() ? "Hide" : "Show"}601                          >602                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>603                              <EyeClosedGlyph />604                            </Show>605                          </button>606                        </div>607                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />608                        <button609                          class="peach-generate-toggle-link"610                          type="button"611                          onClick={() => setShowGenerator(!showGenerator())}612                        >613                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">614                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />615                            <path d="M9.5 2.5H11V1" />616                          </svg>617                          {showGenerator() ? "hide generator" : "generate"}618                        </button>619                        <Show when={showGenerator()}>620                          <GeneratorDrawer621                            open={true}622                            compact={true}623                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}624                          />625                        </Show>626                      </div>627                    </Show>628                  </div>629630                </section>631              </Show>632633              {/* ── Notes (on login entries) ──────────────────────── */}634              <Show when={entry().type !== "note"}>635                <Show636                  when={entry().notes || editingField() === "notes"}637                  fallback={638                    <button639                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"640                      onClick={() => startEdit("notes", "")}641                    >642                      + add notes643                    </button>644                  }645                >646                  <div style="position:relative;z-index:1;margin-top:6px;">647                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>648                    <Show649                      when={editingField() === "notes"}650                      fallback={651                        <div652                          class="peach-note-content peach-inline-clickable"653                          style="cursor:text;white-space:pre-wrap;"654                          onClick={() => startEdit("notes", entry().notes ?? "")}655                        >656                          {entry().notes}657                        </div>658                      }659                    >660                      <div661                        class="peach-inline-field-wrap"662                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}663                      >664                        <textarea665                          class="peach-inline-textarea"666                          value={draft()}667                          onInput={(e) => setDraft(e.currentTarget.value)}668                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}669                          ref={(el) => requestAnimationFrame(() => el?.focus())}670                          rows={4}671                          disabled={saving()}672                        />673                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />674                      </div>675                    </Show>676                  </div>677                </Show>678              </Show>679680              {/* ── TOTP ─────────────────────────────────────────── */}681              <Show when={totpState()}>682                {(state) => (683                  <section class="peach-totp-band">684                    <Show685                      when={editingField() === "totp"}686                      fallback={687                        <>688                          <div689                            class="peach-totp-main peach-inline-clickable"690                            onClick={() => startEdit("totp", entry().totp ?? "")}691                          >692                            <span class="peach-label">2-factor</span>693                            <div class="peach-totp-code-row">694                              <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>695                                <span class="peach-totp-code-text">696                                  {state().code.slice(0, 3)} {state().code.slice(3)}697                                </span>698                              </p>699                              <button700                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}701                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}702                                aria-label="Copy TOTP code"703                              >704                                <CopyGlyph /><span class="peach-copy-sheen" />705                              </button>706                            </div>707                          </div>708                          <div class="peach-totp-meta">709                            <span class="peach-label">expires</span>710                            <div class="peach-totp-countdown">{state().remaining}s</div>711                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">712                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />713                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"714                                stroke-dasharray="106.8"715                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}716                              />717                            </svg>718                          </div>719                        </>720                      }721                    >722                      <div723                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"724                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}725                      >726                        <span class="peach-label">2-factor</span>727                        <input728                          class="peach-inline-input"729                          value={draft()}730                          onInput={(e) => setDraft(e.currentTarget.value)}731                          onKeyDown={onEditKeyDown}732                          ref={autoFocus}733                          disabled={saving()}734                          placeholder="otpauth://... or base32 secret"735                          autocomplete="off"736                        />737                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />738                      </div>739                    </Show>740                  </section>741                )}742              </Show>743744              {/* ── Bottom spacer ─────────────────────────────────── */}745              <div style="height:4px;" />746            </>747          )}748        </Show>749      </div>750751      <Show when={noteEditing()}>752        <Portal mount={document.getElementById("hd-shell") ?? document.body}>753          <NoteEditor754            initialContent={props.entry?.notes ?? ""}755            saving={saving()}756            onSave={saveNote}757            onCancel={() => setNoteEditing(false)}758          />759        </Portal>760      </Show>761    </>762  );763}764765// ── Sub-components ────────────────────────────────────────────────────766767function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {768  return (769    <div class="peach-inline-actions">770      <button771        class="peach-inline-save-btn"772        onClick={props.onSave}773        disabled={props.saving}774        aria-label="Save"775        title="Save (Enter)"776      >777        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">778          <path d="M1 4.5L4 7.5L10 1.5" />779        </svg>780      </button>781      <button782        class="peach-inline-cancel-btn"783        onClick={props.onCancel}784        disabled={props.saving}785        aria-label="Cancel"786        title="Cancel (Esc)"787      >788        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">789          <path d="M1 1L8 8M8 1L1 8" />790        </svg>791      </button>792    </div>793  );794}795796function EmptyVaultState() {797  return (798    <>799      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">800        <div class="peach-entry-name">801          <span class="peach-entry-name-text">802            <span class="peach-entry-name-char" style="--char-index:0">e</span>803            <span class="peach-entry-name-char" style="--char-index:1">m</span>804            <span class="peach-entry-name-char" style="--char-index:2">p</span>805            <span class="peach-entry-name-char" style="--char-index:3">t</span>806            <span class="peach-entry-name-char" style="--char-index:4">y</span>807          </span>808        </div>809      </div>810      <div class="peach-empty-copy">811        <span class="peach-label">vault</span>812        <p>Your vault is ready for its first entry.</p>813      </div>814    </>815  );816}817818function CopyGlyph() {819  return (820    <span class="peach-copy-icon">821      <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">822        <rect x="4" y="4" width="8" height="9" rx="1.5" />823        <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" />824      </svg>825    </span>826  );827}828829function EyeClosedGlyph() {830  return (831    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">832      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />833      <path d="M3.5 5.5L2.5 7.5" />834      <path d="M7 6.5V8.5" />835      <path d="M10.5 5.5L11.5 7.5" />836    </svg>837  );838}839840function EyeOpenGlyph() {841  return (842    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">843      <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" />844      <circle cx="7" cy="5" r="1.75" />845    </svg>846  );847}848849function ChevronGlyph(props: { open: boolean }) {850  return (851    <svg852      width="8"853      height="8"854      viewBox="0 0 8 8"855      fill="none"856      stroke="currentColor"857      stroke-width="1.3"858      stroke-linecap="round"859      stroke-linejoin="round"860      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}861    >862      <path d="M2 1L6 4L2 7" />863    </svg>864  );865}866867function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {868  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));869  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");870  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");871  const [testResult, setTestResult] = createSignal<string | null>(null);872  const [testing, setTesting] = createSignal(false);873874  async function save() {875    const url = webhookUrl().trim();876    const nextLabel = label().trim() || undefined;877    const canary = enabled() && url878      ? {879          webhookUrl: url,880          label: nextLabel,881          lastPinged: props.entry.canary?.lastPinged,882          pingCount: props.entry.canary?.pingCount,883        }884      : undefined;885    await sendMessage({886      type: "UPDATE_ENTRY",887      payload: { id: props.entry.id, patch: { canary } },888    });889    await props.onSaved();890  }891892  async function testPing() {893    const url = webhookUrl().trim();894    if (!url) return;895    setTesting(true);896    setTestResult(null);897    const res = await sendMessage({898      type: "FIRE_CANARY_PING",899      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },900    }) as { ok: boolean; error?: string };901    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);902    setTesting(false);903  }904905  return (906    <div class="peach-canary-section">907      <div class="peach-field-value-row">908        <span class="peach-field-value" style="font-size:12px;">canary alert</span>909        <button910          class={`peach-toggle ${enabled() ? "is-on" : ""}`}911          onClick={() => { setEnabled(!enabled()); void save(); }}912          aria-label="Toggle canary"913        >914          <span class="peach-toggle-knob" />915        </button>916      </div>917      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">918        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.919      </p>920      <Show when={enabled()}>921        <div class="peach-inline-field-wrap" style="margin-top:8px;">922          <input923            class="peach-inline-input"924            placeholder="https://hooks.zapier.com/..."925            value={webhookUrl()}926            onInput={(e) => setWebhookUrl(e.currentTarget.value)}927            onBlur={() => void save()}928          />929          <input930            class="peach-inline-input"931            placeholder="label (optional, e.g. AWS decoy)"932            value={label()}933            onInput={(e) => setLabel(e.currentTarget.value)}934            onBlur={() => void save()}935          />936          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">937            <button938              class="peach-btn peach-btn--ghost peach-btn--sm"939              onClick={() => void testPing()}940              disabled={testing() || !webhookUrl().trim()}941            >942              {testing() ? "sending..." : "test ping"}943            </button>944            <Show when={testResult()}>945              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>946            </Show>947          </div>948          <Show when={props.entry.canary?.lastPinged}>949            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">950              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}951              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts952            </p>953          </Show>954        </div>955      </Show>956    </div>957  );958}