Entry.tsx

39.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";89type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1011interface EntryProps {12  entry: VaultEntry | null;13  onDelete: () => void;14  /** Called after any inline save so App can reload entries */15  onFieldSaved: () => void | Promise<void>;16  onEscape?: () => void;17}1819export function EntryView(props: EntryProps) {20  const [editingField, setEditingField] = createSignal<EditableField>(null);21  const [draft, setDraft] = createSignal("");22  const [saving, setSaving] = createSignal(false);23  const [showPassword, setShowPassword] = createSignal(false);24  const [showGenerator, setShowGenerator] = createSignal(false);25  const [copied, setCopied] = createSignal<string | null>(null);26  const [totpTick, setTotpTick] = createSignal(0);27  const [confirmDelete, setConfirmDelete] = createSignal(false);28  const [totpHot, setTotpHot] = createSignal(false);29  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3031  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);32  onCleanup(() => window.clearInterval(interval));3334  const displayDomain = createMemo(() =>35    props.entry?.url ? getDisplayDomain(props.entry.url) : ""36  );3738  createEffect(() => {39    const domain = displayDomain();40    setFaviconSrc(domain ? faviconUrl(domain) : null);41  });4243  const totpState = createMemo(() => {44    void totpTick();45    if (!props.entry?.totp) return null;46    return generateTOTP(props.entry.totp);47  });4849  let prevTotpCode: string | undefined;50  createEffect(() => {51    const state = totpState();52    if (!state) { prevTotpCode = undefined; return; }53    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {54      setTotpHot(true);55      window.setTimeout(() => setTotpHot(false), 900);56    }57    prevTotpCode = state.code;58  });5960  const totpProgress = createMemo(() => {61    const state = totpState();62    if (!state) return 0;63    return ((state.remaining - 1) / 29) * 100;64  });6566  createEffect(() => {67    const shell = document.getElementById("hd-shell");68    if (!shell) return;6970    const state = totpState();71    if (!state) {72      delete shell.dataset.totpActive;73      shell.style.removeProperty("--peach-totp-progress");74      return;75    }7677    shell.dataset.totpActive = "true";78    shell.style.setProperty(79      "--peach-totp-progress",80      `${Math.max(0, totpProgress())}%`81    );8283    onCleanup(() => {84      delete shell.dataset.totpActive;85      shell.style.removeProperty("--peach-totp-progress");86    });87  });8889  const titleSizeClass = createMemo(() => {90    const length = props.entry?.title.length ?? 0;91    if (length >= 28) return "peach-entry-name--xxl";92    if (length >= 20) return "peach-entry-name--xl";93    if (length >= 14) return "peach-entry-name--lg";94    return "";95  });96  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);9798  // ── Inline editing ────────────────────────────────────────────────99100  function startEdit(field: EditableField, value: string) {101    setEditingField(field);102    setDraft(value);103    setShowGenerator(false);104    setConfirmDelete(false);105  }106107  async function saveField() {108    const field = editingField();109    const entry = props.entry;110    if (!field || !entry) return;111    // Don't trim passwords — spaces may be intentional112    let value = field === "password" ? draft() : draft().trim();113    if (field === "totp" && value.startsWith("otpauth://")) {114      const parsed = parseOtpauthURI(value);115      value = parsed?.secret ?? value;116    }117    if (field === "password" && entry.type !== "note" && !value) {118      return;119    }120    setSaving(true);121    try {122      await sendMessage({123        type: "UPDATE_ENTRY",124        payload: { id: entry.id, patch: { [field]: value } },125      });126      triggerRing();127      setEditingField(null);128      setShowGenerator(false);129      await props.onFieldSaved();130    } catch {131      // silent — leave editing open132    } finally {133      setSaving(false);134    }135  }136137  function cancelEdit() {138    setEditingField(null);139    setDraft("");140    setShowGenerator(false);141  }142143  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {144    switch (field) {145      case "title":146        return entry.title;147      case "url":148        return entry.url ?? "";149      case "username":150        return entry.username ?? "";151      case "password":152        return entry.password ?? "";153      case "notes":154        return entry.notes ?? "";155      case "totp":156        return entry.totp ?? "";157    }158  }159160  function handleEditorFocusOut(161    field: Exclude<EditableField, null>,162    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }163  ) {164    const nextTarget = event.relatedTarget;165    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;166167    const entry = props.entry;168    if (!entry) return;169170    const currentValue = fieldValue(entry, field);171    const nextValue = field === "password" ? draft() : draft().trim();172    if (nextValue === currentValue) {173      cancelEdit();174    }175  }176177  async function savePassword(password: string) {178    const entry = props.entry;179    if (!entry) return;180    setSaving(true);181    try {182      await sendMessage({183        type: "UPDATE_ENTRY",184        payload: { id: entry.id, patch: { password } },185      });186      triggerRing();187      setShowGenerator(false);188      await props.onFieldSaved();189    } catch {190      // silent191    } finally {192      setSaving(false);193    }194  }195196  // ── Copy ─────────────────────────────────────────────────────────197198  async function copyField(text: string, label: string, button?: HTMLButtonElement) {199    const response = await sendMessage({ type: "COPY", payload: { text, label } });200    if ((response as { ok: boolean }).ok) {201      const entry = props.entry;202      if (entry?.canary?.webhookUrl) {203        const trigger =204          label === "password"205            ? "password_copied"206            : label === "username"207              ? "username_copied"208              : undefined;209        if (trigger) {210          void sendMessage({211            type: "FIRE_CANARY_PING",212            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },213          });214        }215      }216      triggerRing();217      triggerPipPulse();218      setCopied(null);219      requestAnimationFrame(() => setCopied(label));220      if (button) {221        button.classList.remove("is-copied", "is-retreat");222        void button.offsetWidth;223        const drain = document.createElement("span");224        drain.className = "peach-copy-drain";225        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";226        drain.appendChild(bar);227        button.appendChild(drain);228        window.setTimeout(() => {229          button.classList.remove("is-copied");230          button.classList.add("is-retreat");231          drain.remove();232          window.setTimeout(() => button.classList.remove("is-retreat"), 160);233        }, 1800);234      }235      window.setTimeout(() => setCopied(null), 1800);236    }237  }238239  // ── Keyboard shortcuts ────────────────────────────────────────────240241  createEffect(() => {242    if (!props.entry) return;243    const handler = (e: KeyboardEvent) => {244      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;245      if (inInput) return;246      switch (e.key) {247        case "c": e.preventDefault();248          if (props.entry?.password) void copyField(props.entry.password, "password"); break;249        case "u": e.preventDefault();250          if (props.entry?.username) void copyField(props.entry.username, "username"); break;251        case "Escape": e.preventDefault(); props.onEscape?.(); break;252      }253    };254    window.addEventListener("keydown", handler);255    onCleanup(() => window.removeEventListener("keydown", handler));256  });257258  // ── Input ref focus helper ────────────────────────────────────────259260  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {261    requestAnimationFrame(() => { el.focus(); el.select?.(); });262  }263264  function onEditKeyDown(e: KeyboardEvent) {265    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {266      e.preventDefault();267      void saveField();268    }269    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }270  }271272  return (273    <>274      <div275        class="peach-ghost"276        aria-hidden="true"277        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;"278      >279        {props.entry?.title || "empty"}280      </div>281282      <div class="peach-stage" style="position:relative;">283        <Show when={props.entry} fallback={<EmptyVaultState />}>284          {(entry) => (285            <>286              {/* ── Breach strip ─────────────────────────────────── */}287              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>288                <div class="peach-breach-badge">289                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">290                    <path d="M6 1L11 10H1L6 1z" />291                    <line x1="6" y1="5" x2="6" y2="7.5" />292                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />293                  </svg>294                  <span style="flex:1">found in a known data breach</span>295                  <button296                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"297                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}298                  >299                    Change password300                  </button>301                </div>302              </Show>303304              {/* ── Title + trash ─────────────────────────────────── */}305              <div class="peach-entry-header">306                <div class="peach-entry-header-name">307                  <Show308                    when={editingField() === "title"}309                    fallback={310                      <button311                        class="peach-entry-name-button"312                        title="Click to edit title"313                        onClick={() => startEdit("title", entry().title)}314                      >315                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>316                          <span class="peach-entry-name-text">317                            <Show318                              when={useAnimatedTitle()}319                              fallback={entry().title}320                            >321                              <For each={entry().title.split("")}>322                                {(ch, i) => (323                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>324                                    {ch === " " ? "\u00A0" : ch}325                                  </span>326                                )}327                              </For>328                            </Show>329                          </span>330                        </h1>331                      </button>332                    }333                  >334                    <div335                      class="peach-inline-title-wrap"336                      onFocusOut={(event) => handleEditorFocusOut("title", event)}337                    >338                      <input339                        class="peach-inline-title-input"340                        value={draft()}341                        onInput={(e) => setDraft(e.currentTarget.value)}342                        onKeyDown={onEditKeyDown}343                        ref={autoFocus}344                        disabled={saving()}345                      />346                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />347                    </div>348                  </Show>349                </div>350351                {/* Subtle trash icon — hidden while editing title */}352                <Show when={editingField() !== "title"}>353                  <Show354                    when={confirmDelete()}355                    fallback={356                      <button357                        class="peach-entry-trash-btn"358                        onClick={() => setConfirmDelete(true)}359                        aria-label="Move to trash"360                        title="Delete entry"361                      >362                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">363                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />364                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />365                        </svg>366                      </button>367                    }368                  >369                    <div class="peach-entry-trash-confirm">370                      <span class="peach-entry-trash-confirm-label">trash?</span>371                      <button372                        class="peach-btn peach-btn--sm peach-btn--danger"373                        style="font-size:10px;padding:2px 8px;min-height:22px;"374                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}375                      >376                        yes377                      </button>378                      <button379                        class="peach-btn peach-btn--ghost peach-btn--sm"380                        style="font-size:10px;padding:2px 8px;min-height:22px;"381                        onClick={() => setConfirmDelete(false)}382                      >383                        no384                      </button>385                    </div>386                  </Show>387                </Show>388              </div>389390              {/* ── Domain / URL ─────────────────────────────────── */}391              <Show when={entry().type !== "note"}>392                <div class="peach-domain" style="position:relative;z-index:1;">393                  <Show394                    when={editingField() === "url"}395                    fallback={396                      <div397                        class="peach-domain-row peach-inline-clickable"398                        title="Click to edit URL"399                        onClick={() => startEdit("url", entry().url ?? "")}400                      >401                        <Show when={faviconSrc()}>402                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />403                        </Show>404                        <span class="peach-domain-value">405                          {displayDomain() || (entry().url ? entry().url : "add a website url")}406                        </span>407                        <span class="peach-inline-edit-hint">edit</span>408                      </div>409                    }410                  >411                    <div412                      class="peach-inline-field-wrap"413                      onFocusOut={(event) => handleEditorFocusOut("url", event)}414                    >415                      <input416                        class="peach-inline-input"417                        type="url"418                        placeholder="https://"419                        value={draft()}420                        onInput={(e) => setDraft(e.currentTarget.value)}421                        onKeyDown={onEditKeyDown}422                        ref={autoFocus}423                        disabled={saving()}424                      />425                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />426                    </div>427                  </Show>428                </div>429              </Show>430431              {/* ── Tags ─────────────────────────────────────────── */}432              <Show when={entry().tags && entry().tags!.length > 0}>433                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">434                  <For each={entry().tags!}>435                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}436                  </For>437                </div>438              </Show>439440              {/* ── Note content ─────────────────────────────────── */}441              <Show when={entry().type === "note"}>442                <section class="peach-fields" style="position:relative;z-index:1;">443                  <Show444                    when={editingField() === "notes"}445                    fallback={446                      <div447                        class="peach-note-content peach-inline-clickable"448                        style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"449                        title="Click to edit"450                        onClick={() => startEdit("notes", entry().notes ?? "")}451                      >452                        {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}453                      </div>454                    }455                  >456                    <div457                      class="peach-inline-field-wrap"458                      onFocusOut={(event) => handleEditorFocusOut("notes", event)}459                    >460                      <textarea461                        class="peach-inline-textarea"462                        value={draft()}463                        onInput={(e) => setDraft(e.currentTarget.value)}464                        onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}465                        ref={(el) => requestAnimationFrame(() => el?.focus())}466                        rows={6}467                        disabled={saving()}468                      />469                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />470                    </div>471                  </Show>472                </section>473              </Show>474475              {/* ── Login fields ─────────────────────────────────── */}476              <Show when={entry().type !== "note"}>477                <section class="peach-fields" style="position:relative;z-index:1;">478479                  {/* Username */}480                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>481                    <span class="peach-label">username</span>482                    <Show483                      when={editingField() === "username"}484                      fallback={485                        <div class="peach-field-value-row">486                          <span487                            class="peach-field-value peach-inline-clickable"488                            title="Click to edit"489                            onClick={() => startEdit("username", entry().username ?? "")}490                          >491                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}492                          </span>493                          <Show when={entry().username}>494                            <button495                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}496                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}497                              aria-label="Copy username"498                            >499                              <CopyGlyph /><span class="peach-copy-sheen" />500                            </button>501                          </Show>502                        </div>503                      }504                    >505                      <div506                        class="peach-inline-field-wrap"507                        onFocusOut={(event) => handleEditorFocusOut("username", event)}508                      >509                        <input510                          class="peach-inline-input"511                          value={draft()}512                          onInput={(e) => setDraft(e.currentTarget.value)}513                          onKeyDown={onEditKeyDown}514                          ref={autoFocus}515                          disabled={saving()}516                          autocomplete="off"517                        />518                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />519                      </div>520                    </Show>521                  </div>522523                  {/* Password */}524                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>525                    <span class="peach-label">password</span>526                    <Show527                      when={editingField() === "password"}528                      fallback={529                        <div class="peach-field-value-row">530                          <span531                            class="peach-password-value"532                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}533                            style="cursor:pointer;"534                            title="Click to edit password"535                            onClick={() => startEdit("password", entry().password ?? "")}536                          >537                            <Show538                              when={showPassword()}539                              fallback={<span class="peach-password-mask">...............</span>}540                            >541                              <span class="peach-password-text">{entry().password ?? ""}</span>542                            </Show>543                          </span>544                          <div class="peach-field-actions">545                            <button546                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"547                              onClick={() => setShowPassword(!showPassword())}548                              aria-label="Reveal password"549                            >550                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>551                                <EyeClosedGlyph />552                              </Show>553                            </button>554                            <button555                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}556                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}557                              aria-label="Copy password"558                            >559                              <CopyGlyph /><span class="peach-copy-sheen" />560                            </button>561                          </div>562                        </div>563                      }564                    >565                      {/* ── Edit mode: type / paste / generate ── */}566                      <div567                        class="peach-inline-field-wrap"568                        onFocusOut={(event) => handleEditorFocusOut("password", event)}569                      >570                        <div class="peach-password-edit-row">571                          <input572                            class="peach-inline-input"573                            type={showPassword() ? "text" : "password"}574                            placeholder="paste or type a password"575                            value={draft()}576                            onInput={(e) => setDraft(e.currentTarget.value)}577                            onKeyDown={(e) => {578                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }579                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }580                            }}581                            ref={autoFocus}582                            disabled={saving()}583                            autocomplete="new-password"584                          />585                          <button586                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"587                            type="button"588                            onClick={() => setShowPassword(!showPassword())}589                            aria-label={showPassword() ? "Hide" : "Show"}590                          >591                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>592                              <EyeClosedGlyph />593                            </Show>594                          </button>595                        </div>596                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />597                        <button598                          class="peach-generate-toggle-link"599                          type="button"600                          onClick={() => setShowGenerator(!showGenerator())}601                        >602                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">603                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />604                            <path d="M9.5 2.5H11V1" />605                          </svg>606                          {showGenerator() ? "hide generator" : "generate"}607                        </button>608                        <Show when={showGenerator()}>609                          <GeneratorDrawer610                            open={true}611                            compact={true}612                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}613                          />614                        </Show>615                      </div>616                    </Show>617                  </div>618619                </section>620              </Show>621622              {/* ── Notes (on login entries) ──────────────────────── */}623              <Show when={entry().type !== "note"}>624                <Show625                  when={entry().notes || editingField() === "notes"}626                  fallback={627                    <button628                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"629                      onClick={() => startEdit("notes", "")}630                    >631                      + add notes632                    </button>633                  }634                >635                  <div style="position:relative;z-index:1;margin-top:6px;">636                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>637                    <Show638                      when={editingField() === "notes"}639                      fallback={640                        <div641                          class="peach-note-content peach-inline-clickable"642                          style="cursor:text;white-space:pre-wrap;"643                          onClick={() => startEdit("notes", entry().notes ?? "")}644                        >645                          {entry().notes}646                        </div>647                      }648                    >649                      <div650                        class="peach-inline-field-wrap"651                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}652                      >653                        <textarea654                          class="peach-inline-textarea"655                          value={draft()}656                          onInput={(e) => setDraft(e.currentTarget.value)}657                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}658                          ref={(el) => requestAnimationFrame(() => el?.focus())}659                          rows={4}660                          disabled={saving()}661                        />662                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />663                      </div>664                    </Show>665                  </div>666                </Show>667              </Show>668669              {/* ── TOTP ─────────────────────────────────────────── */}670              <Show when={totpState()}>671                {(state) => (672                  <section class="peach-totp-band">673                    <Show674                      when={editingField() === "totp"}675                      fallback={676                        <>677                          <div678                            class="peach-totp-main peach-inline-clickable"679                            onClick={() => startEdit("totp", entry().totp ?? "")}680                          >681                            <span class="peach-label">2-factor</span>682                            <div class="peach-totp-code-row">683                              <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>684                                <span class="peach-totp-code-text">685                                  {state().code.slice(0, 3)} {state().code.slice(3)}686                                </span>687                              </p>688                              <button689                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}690                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}691                                aria-label="Copy TOTP code"692                              >693                                <CopyGlyph /><span class="peach-copy-sheen" />694                              </button>695                            </div>696                          </div>697                          <div class="peach-totp-meta">698                            <span class="peach-label">expires</span>699                            <div class="peach-totp-countdown">{state().remaining}s</div>700                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">701                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />702                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"703                                stroke-dasharray="106.8"704                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}705                              />706                            </svg>707                          </div>708                        </>709                      }710                    >711                      <div712                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"713                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}714                      >715                        <span class="peach-label">2-factor</span>716                        <input717                          class="peach-inline-input"718                          value={draft()}719                          onInput={(e) => setDraft(e.currentTarget.value)}720                          onKeyDown={onEditKeyDown}721                          ref={autoFocus}722                          disabled={saving()}723                          placeholder="otpauth://... or base32 secret"724                          autocomplete="off"725                        />726                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />727                      </div>728                    </Show>729                  </section>730                )}731              </Show>732733              {/* ── Bottom spacer ─────────────────────────────────── */}734              <div style="height:4px;" />735            </>736          )}737        </Show>738      </div>739    </>740  );741}742743// ── Sub-components ────────────────────────────────────────────────────744745function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {746  return (747    <div class="peach-inline-actions">748      <button749        class="peach-inline-save-btn"750        onClick={props.onSave}751        disabled={props.saving}752        aria-label="Save"753        title="Save (Enter)"754      >755        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">756          <path d="M1 4.5L4 7.5L10 1.5" />757        </svg>758      </button>759      <button760        class="peach-inline-cancel-btn"761        onClick={props.onCancel}762        disabled={props.saving}763        aria-label="Cancel"764        title="Cancel (Esc)"765      >766        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">767          <path d="M1 1L8 8M8 1L1 8" />768        </svg>769      </button>770    </div>771  );772}773774function EmptyVaultState() {775  return (776    <>777      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">778        <div class="peach-entry-name">779          <span class="peach-entry-name-text">780            <span class="peach-entry-name-char" style="--char-index:0">e</span>781            <span class="peach-entry-name-char" style="--char-index:1">m</span>782            <span class="peach-entry-name-char" style="--char-index:2">p</span>783            <span class="peach-entry-name-char" style="--char-index:3">t</span>784            <span class="peach-entry-name-char" style="--char-index:4">y</span>785          </span>786        </div>787      </div>788      <div class="peach-empty-copy">789        <span class="peach-label">vault</span>790        <p>Your vault is ready for its first entry.</p>791      </div>792    </>793  );794}795796function CopyGlyph() {797  return (798    <span class="peach-copy-icon">799      <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">800        <rect x="4" y="4" width="8" height="9" rx="1.5" />801        <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" />802      </svg>803    </span>804  );805}806807function EyeClosedGlyph() {808  return (809    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">810      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />811      <path d="M3.5 5.5L2.5 7.5" />812      <path d="M7 6.5V8.5" />813      <path d="M10.5 5.5L11.5 7.5" />814    </svg>815  );816}817818function EyeOpenGlyph() {819  return (820    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">821      <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" />822      <circle cx="7" cy="5" r="1.75" />823    </svg>824  );825}826827function ChevronGlyph(props: { open: boolean }) {828  return (829    <svg830      width="8"831      height="8"832      viewBox="0 0 8 8"833      fill="none"834      stroke="currentColor"835      stroke-width="1.3"836      stroke-linecap="round"837      stroke-linejoin="round"838      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}839    >840      <path d="M2 1L6 4L2 7" />841    </svg>842  );843}844845function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {846  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));847  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");848  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");849  const [testResult, setTestResult] = createSignal<string | null>(null);850  const [testing, setTesting] = createSignal(false);851852  async function save() {853    const url = webhookUrl().trim();854    const nextLabel = label().trim() || undefined;855    const canary = enabled() && url856      ? {857          webhookUrl: url,858          label: nextLabel,859          lastPinged: props.entry.canary?.lastPinged,860          pingCount: props.entry.canary?.pingCount,861        }862      : undefined;863    await sendMessage({864      type: "UPDATE_ENTRY",865      payload: { id: props.entry.id, patch: { canary } },866    });867    await props.onSaved();868  }869870  async function testPing() {871    const url = webhookUrl().trim();872    if (!url) return;873    setTesting(true);874    setTestResult(null);875    const res = await sendMessage({876      type: "FIRE_CANARY_PING",877      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },878    }) as { ok: boolean; error?: string };879    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);880    setTesting(false);881  }882883  return (884    <div class="peach-canary-section">885      <div class="peach-field-value-row">886        <span class="peach-field-value" style="font-size:12px;">canary alert</span>887        <button888          class={`peach-toggle ${enabled() ? "is-on" : ""}`}889          onClick={() => { setEnabled(!enabled()); void save(); }}890          aria-label="Toggle canary"891        >892          <span class="peach-toggle-knob" />893        </button>894      </div>895      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">896        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.897      </p>898      <Show when={enabled()}>899        <div class="peach-inline-field-wrap" style="margin-top:8px;">900          <input901            class="peach-inline-input"902            placeholder="https://hooks.zapier.com/..."903            value={webhookUrl()}904            onInput={(e) => setWebhookUrl(e.currentTarget.value)}905            onBlur={() => void save()}906          />907          <input908            class="peach-inline-input"909            placeholder="label (optional, e.g. AWS decoy)"910            value={label()}911            onInput={(e) => setLabel(e.currentTarget.value)}912            onBlur={() => void save()}913          />914          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">915            <button916              class="peach-btn peach-btn--ghost peach-btn--sm"917              onClick={() => void testPing()}918              disabled={testing() || !webhookUrl().trim()}919            >920              {testing() ? "sending..." : "test ping"}921            </button>922            <Show when={testResult()}>923              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>924            </Show>925          </div>926          <Show when={props.entry.canary?.lastPinged}>927            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">928              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}929              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts930            </p>931          </Show>932        </div>933      </Show>934    </div>935  );936}