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, triggerTotpRing } 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 [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);2930  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);31  onCleanup(() => window.clearInterval(interval));3233  const displayDomain = createMemo(() =>34    props.entry?.url ? getDisplayDomain(props.entry.url) : ""35  );3637  createEffect(() => {38    const domain = displayDomain();39    setFaviconSrc(domain ? faviconUrl(domain) : null);40  });4142  const totpState = createMemo(() => {43    void totpTick();44    if (!props.entry?.totp) return null;45    return generateTOTP(props.entry.totp);46  });4748  let prevTotpCode: string | undefined;49  createEffect(() => {50    const state = totpState();51    if (!state) { prevTotpCode = undefined; return; }52    if (prevTotpCode !== undefined && state.code !== prevTotpCode) triggerTotpRing();53    prevTotpCode = state.code;54  });5556  const totpProgress = createMemo(() => {57    const state = totpState();58    if (!state) return 0;59    return ((state.remaining - 1) / 29) * 100;60  });6162  createEffect(() => {63    const shell = document.getElementById("hd-shell");64    if (!shell) return;6566    const state = totpState();67    if (!state) {68      delete shell.dataset.totpActive;69      shell.style.removeProperty("--peach-totp-progress");70      return;71    }7273    shell.dataset.totpActive = "true";74    shell.style.setProperty(75      "--peach-totp-progress",76      `${Math.max(0, totpProgress())}%`77    );7879    onCleanup(() => {80      delete shell.dataset.totpActive;81      shell.style.removeProperty("--peach-totp-progress");82    });83  });8485  const titleSizeClass = createMemo(() => {86    const length = props.entry?.title.length ?? 0;87    if (length >= 28) return "peach-entry-name--xxl";88    if (length >= 20) return "peach-entry-name--xl";89    if (length >= 14) return "peach-entry-name--lg";90    return "";91  });92  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);9394  // ── Inline editing ────────────────────────────────────────────────9596  function startEdit(field: EditableField, value: string) {97    setEditingField(field);98    setDraft(value);99    setShowGenerator(false);100    setConfirmDelete(false);101  }102103  async function saveField() {104    const field = editingField();105    const entry = props.entry;106    if (!field || !entry) return;107    // Don't trim passwords — spaces may be intentional108    let value = field === "password" ? draft() : draft().trim();109    if (field === "totp" && value.startsWith("otpauth://")) {110      const parsed = parseOtpauthURI(value);111      value = parsed?.secret ?? value;112    }113    if (field === "password" && entry.type !== "note" && !value) {114      return;115    }116    setSaving(true);117    try {118      await sendMessage({119        type: "UPDATE_ENTRY",120        payload: { id: entry.id, patch: { [field]: value } },121      });122      triggerRing();123      setEditingField(null);124      setShowGenerator(false);125      await props.onFieldSaved();126    } catch {127      // silent — leave editing open128    } finally {129      setSaving(false);130    }131  }132133  function cancelEdit() {134    setEditingField(null);135    setDraft("");136    setShowGenerator(false);137  }138139  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {140    switch (field) {141      case "title":142        return entry.title;143      case "url":144        return entry.url ?? "";145      case "username":146        return entry.username ?? "";147      case "password":148        return entry.password ?? "";149      case "notes":150        return entry.notes ?? "";151      case "totp":152        return entry.totp ?? "";153    }154  }155156  function handleEditorFocusOut(157    field: Exclude<EditableField, null>,158    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }159  ) {160    const nextTarget = event.relatedTarget;161    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;162163    const entry = props.entry;164    if (!entry) return;165166    const currentValue = fieldValue(entry, field);167    const nextValue = field === "password" ? draft() : draft().trim();168    if (nextValue === currentValue) {169      cancelEdit();170    }171  }172173  async function savePassword(password: string) {174    const entry = props.entry;175    if (!entry) return;176    setSaving(true);177    try {178      await sendMessage({179        type: "UPDATE_ENTRY",180        payload: { id: entry.id, patch: { password } },181      });182      triggerRing();183      setShowGenerator(false);184      await props.onFieldSaved();185    } catch {186      // silent187    } finally {188      setSaving(false);189    }190  }191192  // ── Copy ─────────────────────────────────────────────────────────193194  async function copyField(text: string, label: string, button?: HTMLButtonElement) {195    const response = await sendMessage({ type: "COPY", payload: { text, label } });196    if ((response as { ok: boolean }).ok) {197      const entry = props.entry;198      if (entry?.canary?.webhookUrl) {199        const trigger =200          label === "password"201            ? "password_copied"202            : label === "username"203              ? "username_copied"204              : undefined;205        if (trigger) {206          void sendMessage({207            type: "FIRE_CANARY_PING",208            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },209          });210        }211      }212      triggerRing();213      triggerPipPulse();214      setCopied(null);215      requestAnimationFrame(() => setCopied(label));216      if (button) {217        button.classList.remove("is-copied", "is-retreat");218        void button.offsetWidth;219        const drain = document.createElement("span");220        drain.className = "peach-copy-drain";221        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";222        drain.appendChild(bar);223        button.appendChild(drain);224        window.setTimeout(() => {225          button.classList.remove("is-copied");226          button.classList.add("is-retreat");227          drain.remove();228          window.setTimeout(() => button.classList.remove("is-retreat"), 160);229        }, 1800);230      }231      window.setTimeout(() => setCopied(null), 1800);232    }233  }234235  // ── Keyboard shortcuts ────────────────────────────────────────────236237  createEffect(() => {238    if (!props.entry) return;239    const handler = (e: KeyboardEvent) => {240      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;241      if (inInput) return;242      switch (e.key) {243        case "c": e.preventDefault();244          if (props.entry?.password) void copyField(props.entry.password, "password"); break;245        case "u": e.preventDefault();246          if (props.entry?.username) void copyField(props.entry.username, "username"); break;247        case "Escape": e.preventDefault(); props.onEscape?.(); break;248      }249    };250    window.addEventListener("keydown", handler);251    onCleanup(() => window.removeEventListener("keydown", handler));252  });253254  // ── Input ref focus helper ────────────────────────────────────────255256  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {257    requestAnimationFrame(() => { el.focus(); el.select?.(); });258  }259260  function onEditKeyDown(e: KeyboardEvent) {261    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {262      e.preventDefault();263      void saveField();264    }265    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }266  }267268  return (269    <>270      <div271        class="peach-ghost"272        aria-hidden="true"273        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;"274      >275        {props.entry?.title || "empty"}276      </div>277278      <div class="peach-stage" style="position:relative;">279        <Show when={props.entry} fallback={<EmptyVaultState />}>280          {(entry) => (281            <>282              {/* ── Breach strip ─────────────────────────────────── */}283              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>284                <div class="peach-breach-badge">285                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">286                    <path d="M6 1L11 10H1L6 1z" />287                    <line x1="6" y1="5" x2="6" y2="7.5" />288                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />289                  </svg>290                  <span style="flex:1">found in a known data breach</span>291                  <button292                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"293                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}294                  >295                    Change password296                  </button>297                </div>298              </Show>299300              {/* ── Title + trash ─────────────────────────────────── */}301              <div class="peach-entry-header">302                <div class="peach-entry-header-name">303                  <Show304                    when={editingField() === "title"}305                    fallback={306                      <button307                        class="peach-entry-name-button"308                        title="Click to edit title"309                        onClick={() => startEdit("title", entry().title)}310                      >311                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>312                          <span class="peach-entry-name-text">313                            <Show314                              when={useAnimatedTitle()}315                              fallback={entry().title}316                            >317                              <For each={entry().title.split("")}>318                                {(ch, i) => (319                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>320                                    {ch === " " ? "\u00A0" : ch}321                                  </span>322                                )}323                              </For>324                            </Show>325                          </span>326                        </h1>327                      </button>328                    }329                  >330                    <div331                      class="peach-inline-title-wrap"332                      onFocusOut={(event) => handleEditorFocusOut("title", event)}333                    >334                      <input335                        class="peach-inline-title-input"336                        value={draft()}337                        onInput={(e) => setDraft(e.currentTarget.value)}338                        onKeyDown={onEditKeyDown}339                        ref={autoFocus}340                        disabled={saving()}341                      />342                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />343                    </div>344                  </Show>345                </div>346347                {/* Subtle trash icon — hidden while editing title */}348                <Show when={editingField() !== "title"}>349                  <Show350                    when={confirmDelete()}351                    fallback={352                      <button353                        class="peach-entry-trash-btn"354                        onClick={() => setConfirmDelete(true)}355                        aria-label="Move to trash"356                        title="Delete entry"357                      >358                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">359                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />360                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />361                        </svg>362                      </button>363                    }364                  >365                    <div class="peach-entry-trash-confirm">366                      <span class="peach-entry-trash-confirm-label">trash?</span>367                      <button368                        class="peach-btn peach-btn--sm peach-btn--danger"369                        style="font-size:10px;padding:2px 8px;min-height:22px;"370                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}371                      >372                        yes373                      </button>374                      <button375                        class="peach-btn peach-btn--ghost peach-btn--sm"376                        style="font-size:10px;padding:2px 8px;min-height:22px;"377                        onClick={() => setConfirmDelete(false)}378                      >379                        no380                      </button>381                    </div>382                  </Show>383                </Show>384              </div>385386              {/* ── Domain / URL ─────────────────────────────────── */}387              <Show when={entry().type !== "note"}>388                <div class="peach-domain" style="position:relative;z-index:1;">389                  <Show390                    when={editingField() === "url"}391                    fallback={392                      <div393                        class="peach-domain-row peach-inline-clickable"394                        title="Click to edit URL"395                        onClick={() => startEdit("url", entry().url ?? "")}396                      >397                        <Show when={faviconSrc()}>398                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />399                        </Show>400                        <span class="peach-domain-value">401                          {displayDomain() || (entry().url ? entry().url : "add a website url")}402                        </span>403                        <span class="peach-inline-edit-hint">edit</span>404                      </div>405                    }406                  >407                    <div408                      class="peach-inline-field-wrap"409                      onFocusOut={(event) => handleEditorFocusOut("url", event)}410                    >411                      <input412                        class="peach-inline-input"413                        type="url"414                        placeholder="https://"415                        value={draft()}416                        onInput={(e) => setDraft(e.currentTarget.value)}417                        onKeyDown={onEditKeyDown}418                        ref={autoFocus}419                        disabled={saving()}420                      />421                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />422                    </div>423                  </Show>424                </div>425              </Show>426427              {/* ── Tags ─────────────────────────────────────────── */}428              <Show when={entry().tags && entry().tags!.length > 0}>429                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">430                  <For each={entry().tags!}>431                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}432                  </For>433                </div>434              </Show>435436              {/* ── Note content ─────────────────────────────────── */}437              <Show when={entry().type === "note"}>438                <section class="peach-fields" style="position:relative;z-index:1;">439                  <Show440                    when={editingField() === "notes"}441                    fallback={442                      <div443                        class="peach-note-content peach-inline-clickable"444                        style="display:block;white-space:pre-wrap;overflow:auto;max-height:340px;cursor:text;"445                        title="Click to edit"446                        onClick={() => startEdit("notes", entry().notes ?? "")}447                      >448                        {entry().notes || <span style="color:var(--t2);font-style:italic;">click to add notes</span>}449                      </div>450                    }451                  >452                    <div453                      class="peach-inline-field-wrap"454                      onFocusOut={(event) => handleEditorFocusOut("notes", event)}455                    >456                      <textarea457                        class="peach-inline-textarea"458                        value={draft()}459                        onInput={(e) => setDraft(e.currentTarget.value)}460                        onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}461                        ref={(el) => requestAnimationFrame(() => el?.focus())}462                        rows={6}463                        disabled={saving()}464                      />465                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />466                    </div>467                  </Show>468                </section>469              </Show>470471              {/* ── Login fields ─────────────────────────────────── */}472              <Show when={entry().type !== "note"}>473                <section class="peach-fields" style="position:relative;z-index:1;">474475                  {/* Username */}476                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>477                    <span class="peach-label">username</span>478                    <Show479                      when={editingField() === "username"}480                      fallback={481                        <div class="peach-field-value-row">482                          <span483                            class="peach-field-value peach-inline-clickable"484                            title="Click to edit"485                            onClick={() => startEdit("username", entry().username ?? "")}486                          >487                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}488                          </span>489                          <Show when={entry().username}>490                            <button491                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}492                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}493                              aria-label="Copy username"494                            >495                              <CopyGlyph /><span class="peach-copy-sheen" />496                            </button>497                          </Show>498                        </div>499                      }500                    >501                      <div502                        class="peach-inline-field-wrap"503                        onFocusOut={(event) => handleEditorFocusOut("username", event)}504                      >505                        <input506                          class="peach-inline-input"507                          value={draft()}508                          onInput={(e) => setDraft(e.currentTarget.value)}509                          onKeyDown={onEditKeyDown}510                          ref={autoFocus}511                          disabled={saving()}512                          autocomplete="off"513                        />514                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />515                      </div>516                    </Show>517                  </div>518519                  {/* Password */}520                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>521                    <span class="peach-label">password</span>522                    <Show523                      when={editingField() === "password"}524                      fallback={525                        <div class="peach-field-value-row">526                          <span527                            class="peach-password-value"528                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}529                            style="cursor:pointer;"530                            title="Click to edit password"531                            onClick={() => startEdit("password", entry().password ?? "")}532                          >533                            <Show534                              when={showPassword()}535                              fallback={<span class="peach-password-mask">...............</span>}536                            >537                              <span class="peach-password-text">{entry().password ?? ""}</span>538                            </Show>539                          </span>540                          <div class="peach-field-actions">541                            <button542                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"543                              onClick={() => setShowPassword(!showPassword())}544                              aria-label="Reveal password"545                            >546                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>547                                <EyeClosedGlyph />548                              </Show>549                            </button>550                            <button551                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}552                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}553                              aria-label="Copy password"554                            >555                              <CopyGlyph /><span class="peach-copy-sheen" />556                            </button>557                          </div>558                        </div>559                      }560                    >561                      {/* ── Edit mode: type / paste / generate ── */}562                      <div563                        class="peach-inline-field-wrap"564                        onFocusOut={(event) => handleEditorFocusOut("password", event)}565                      >566                        <div class="peach-password-edit-row">567                          <input568                            class="peach-inline-input"569                            type={showPassword() ? "text" : "password"}570                            placeholder="paste or type a password"571                            value={draft()}572                            onInput={(e) => setDraft(e.currentTarget.value)}573                            onKeyDown={(e) => {574                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }575                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }576                            }}577                            ref={autoFocus}578                            disabled={saving()}579                            autocomplete="new-password"580                          />581                          <button582                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"583                            type="button"584                            onClick={() => setShowPassword(!showPassword())}585                            aria-label={showPassword() ? "Hide" : "Show"}586                          >587                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>588                              <EyeClosedGlyph />589                            </Show>590                          </button>591                        </div>592                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />593                        <button594                          class="peach-generate-toggle-link"595                          type="button"596                          onClick={() => setShowGenerator(!showGenerator())}597                        >598                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">599                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />600                            <path d="M9.5 2.5H11V1" />601                          </svg>602                          {showGenerator() ? "hide generator" : "generate"}603                        </button>604                        <Show when={showGenerator()}>605                          <GeneratorDrawer606                            open={true}607                            compact={true}608                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}609                          />610                        </Show>611                      </div>612                    </Show>613                  </div>614615                </section>616              </Show>617618              {/* ── Notes (on login entries) ──────────────────────── */}619              <Show when={entry().type !== "note"}>620                <Show621                  when={entry().notes || editingField() === "notes"}622                  fallback={623                    <button624                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"625                      onClick={() => startEdit("notes", "")}626                    >627                      + add notes628                    </button>629                  }630                >631                  <div style="position:relative;z-index:1;margin-top:6px;">632                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>633                    <Show634                      when={editingField() === "notes"}635                      fallback={636                        <div637                          class="peach-note-content peach-inline-clickable"638                          style="cursor:text;white-space:pre-wrap;"639                          onClick={() => startEdit("notes", entry().notes ?? "")}640                        >641                          {entry().notes}642                        </div>643                      }644                    >645                      <div646                        class="peach-inline-field-wrap"647                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}648                      >649                        <textarea650                          class="peach-inline-textarea"651                          value={draft()}652                          onInput={(e) => setDraft(e.currentTarget.value)}653                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}654                          ref={(el) => requestAnimationFrame(() => el?.focus())}655                          rows={4}656                          disabled={saving()}657                        />658                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />659                      </div>660                    </Show>661                  </div>662                </Show>663              </Show>664665              {/* ── TOTP ─────────────────────────────────────────── */}666              <Show when={totpState()}>667                {(state) => (668                  <section class={`peach-totp-band${state().remaining <= 9 ? " is-urgent" : ""}`}>669                    <Show670                      when={editingField() === "totp"}671                      fallback={672                        <>673                          <div674                            class="peach-totp-main peach-inline-clickable"675                            title="Click to edit 2FA secret"676                            onClick={() => startEdit("totp", entry().totp ?? "")}677                          >678                            <span class="peach-label">2-factor</span>679                            <div class="peach-totp-code-row">680                              <p class="peach-totp-code">681                                <span class="peach-totp-code-text">682                                  {state().code.slice(0, 3)} {state().code.slice(3)}683                                </span>684                              </p>685                              <button686                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}687                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}688                                aria-label="Copy TOTP code"689                              >690                                <CopyGlyph /><span class="peach-copy-sheen" />691                              </button>692                            </div>693                          </div>694                          <div class="peach-totp-meta">695                            <span class="peach-label">expires</span>696                            <div class="peach-totp-countdown">{state().remaining}s</div>697                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">698                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />699                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"700                                stroke-dasharray="106.8"701                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}702                              />703                            </svg>704                          </div>705                        </>706                      }707                    >708                      <div709                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"710                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}711                      >712                        <span class="peach-label">2-factor</span>713                        <input714                          class="peach-inline-input"715                          value={draft()}716                          onInput={(e) => setDraft(e.currentTarget.value)}717                          onKeyDown={onEditKeyDown}718                          ref={autoFocus}719                          disabled={saving()}720                          placeholder="otpauth://... or base32 secret"721                          autocomplete="off"722                        />723                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />724                      </div>725                    </Show>726                  </section>727                )}728              </Show>729730              {/* ── Bottom spacer ─────────────────────────────────── */}731              <div style="height:4px;" />732            </>733          )}734        </Show>735      </div>736    </>737  );738}739740// ── Sub-components ────────────────────────────────────────────────────741742function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {743  return (744    <div class="peach-inline-actions">745      <button746        class="peach-inline-save-btn"747        onClick={props.onSave}748        disabled={props.saving}749        aria-label="Save"750        title="Save (Enter)"751      >752        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">753          <path d="M1 4.5L4 7.5L10 1.5" />754        </svg>755      </button>756      <button757        class="peach-inline-cancel-btn"758        onClick={props.onCancel}759        disabled={props.saving}760        aria-label="Cancel"761        title="Cancel (Esc)"762      >763        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">764          <path d="M1 1L8 8M8 1L1 8" />765        </svg>766      </button>767    </div>768  );769}770771function EmptyVaultState() {772  return (773    <>774      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">775        <div class="peach-entry-name">776          <span class="peach-entry-name-text">777            <span class="peach-entry-name-char" style="--char-index:0">e</span>778            <span class="peach-entry-name-char" style="--char-index:1">m</span>779            <span class="peach-entry-name-char" style="--char-index:2">p</span>780            <span class="peach-entry-name-char" style="--char-index:3">t</span>781            <span class="peach-entry-name-char" style="--char-index:4">y</span>782          </span>783        </div>784      </div>785      <div class="peach-empty-copy">786        <span class="peach-label">vault</span>787        <p>Your vault is ready for its first entry.</p>788      </div>789    </>790  );791}792793function CopyGlyph() {794  return (795    <span class="peach-copy-icon">796      <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">797        <rect x="4" y="4" width="8" height="9" rx="1.5" />798        <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" />799      </svg>800    </span>801  );802}803804function EyeClosedGlyph() {805  return (806    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">807      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />808      <path d="M3.5 5.5L2.5 7.5" />809      <path d="M7 6.5V8.5" />810      <path d="M10.5 5.5L11.5 7.5" />811    </svg>812  );813}814815function EyeOpenGlyph() {816  return (817    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">818      <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" />819      <circle cx="7" cy="5" r="1.75" />820    </svg>821  );822}823824function ChevronGlyph(props: { open: boolean }) {825  return (826    <svg827      width="8"828      height="8"829      viewBox="0 0 8 8"830      fill="none"831      stroke="currentColor"832      stroke-width="1.3"833      stroke-linecap="round"834      stroke-linejoin="round"835      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}836    >837      <path d="M2 1L6 4L2 7" />838    </svg>839  );840}841842function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {843  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));844  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");845  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");846  const [testResult, setTestResult] = createSignal<string | null>(null);847  const [testing, setTesting] = createSignal(false);848849  async function save() {850    const url = webhookUrl().trim();851    const nextLabel = label().trim() || undefined;852    const canary = enabled() && url853      ? {854          webhookUrl: url,855          label: nextLabel,856          lastPinged: props.entry.canary?.lastPinged,857          pingCount: props.entry.canary?.pingCount,858        }859      : undefined;860    await sendMessage({861      type: "UPDATE_ENTRY",862      payload: { id: props.entry.id, patch: { canary } },863    });864    await props.onSaved();865  }866867  async function testPing() {868    const url = webhookUrl().trim();869    if (!url) return;870    setTesting(true);871    setTestResult(null);872    const res = await sendMessage({873      type: "FIRE_CANARY_PING",874      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },875    }) as { ok: boolean; error?: string };876    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);877    setTesting(false);878  }879880  return (881    <div class="peach-canary-section">882      <div class="peach-field-value-row">883        <span class="peach-field-value" style="font-size:12px;">canary alert</span>884        <button885          class={`peach-toggle ${enabled() ? "is-on" : ""}`}886          onClick={() => { setEnabled(!enabled()); void save(); }}887          aria-label="Toggle canary"888        >889          <span class="peach-toggle-knob" />890        </button>891      </div>892      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">893        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.894      </p>895      <Show when={enabled()}>896        <div class="peach-inline-field-wrap" style="margin-top:8px;">897          <input898            class="peach-inline-input"899            placeholder="https://hooks.zapier.com/..."900            value={webhookUrl()}901            onInput={(e) => setWebhookUrl(e.currentTarget.value)}902            onBlur={() => void save()}903          />904          <input905            class="peach-inline-input"906            placeholder="label (optional, e.g. AWS decoy)"907            value={label()}908            onInput={(e) => setLabel(e.currentTarget.value)}909            onBlur={() => void save()}910          />911          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">912            <button913              class="peach-btn peach-btn--ghost peach-btn--sm"914              onClick={() => void testPing()}915              disabled={testing() || !webhookUrl().trim()}916            >917              {testing() ? "sending..." : "test ping"}918            </button>919            <Show when={testResult()}>920              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>921            </Show>922          </div>923          <Show when={props.entry.canary?.lastPinged}>924            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">925              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}926              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts927            </p>928          </Show>929        </div>930      </Show>931    </div>932  );933}