Entry.tsx

39.6 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { GeneratorDrawer } from "../components/GeneratorDrawer";8import { NoteEditor } from "./NoteEditor";910type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1112interface EntryProps {13  entry: VaultEntry | null;14  onDelete: () => void;15  /** Called after any inline save so App can reload entries */16  onFieldSaved: () => void | Promise<void>;17  onEscape?: () => void;18}1920export function EntryView(props: EntryProps) {21  const [editingField, setEditingField] = createSignal<EditableField>(null);22  const [draft, setDraft] = createSignal("");23  const [saving, setSaving] = createSignal(false);24  const [showPassword, setShowPassword] = createSignal(false);25  const [showGenerator, setShowGenerator] = createSignal(false);26  const [copied, setCopied] = createSignal<string | null>(null);27  const [totpTick, setTotpTick] = createSignal(0);28  const [confirmDelete, setConfirmDelete] = createSignal(false);29  const [totpHot, setTotpHot] = createSignal(false);30  const [noteEditing, setNoteEditing] = createSignal(false);31  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);3233  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);34  onCleanup(() => window.clearInterval(interval));3536  const displayDomain = createMemo(() =>37    props.entry?.url ? getDisplayDomain(props.entry.url) : ""38  );3940  createEffect(() => {41    const domain = displayDomain();42    setFaviconSrc(domain ? faviconUrl(domain) : null);43  });4445  const totpState = createMemo(() => {46    void totpTick();47    if (!props.entry?.totp) return null;48    return generateTOTP(props.entry.totp);49  });5051  let prevTotpCode: string | undefined;52  createEffect(() => {53    const state = totpState();54    if (!state) { prevTotpCode = undefined; return; }55    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {56      setTotpHot(true);57      window.setTimeout(() => setTotpHot(false), 3000);58    }59    prevTotpCode = state.code;60  });6162  const totpProgress = createMemo(() => {63    const state = totpState();64    if (!state) return 0;65    return ((state.remaining - 1) / 29) * 100;66  });6768  createEffect(() => {69    const shell = document.getElementById("hd-shell");70    if (!shell) return;7172    const state = totpState();73    if (!state) {74      delete shell.dataset.totpActive;75      shell.style.removeProperty("--peach-totp-progress");76      return;77    }7879    shell.dataset.totpActive = "true";80    shell.style.setProperty(81      "--peach-totp-progress",82      `${Math.max(0, totpProgress())}%`83    );8485    onCleanup(() => {86      delete shell.dataset.totpActive;87      shell.style.removeProperty("--peach-totp-progress");88    });89  });9091  const titleSizeClass = createMemo(() => {92    const length = props.entry?.title.length ?? 0;93    if (length >= 28) return "peach-entry-name--xxl";94    if (length >= 20) return "peach-entry-name--xl";95    if (length >= 14) return "peach-entry-name--lg";96    return "";97  });98  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);99100  // ── Inline editing ────────────────────────────────────────────────101102  function startEdit(field: EditableField, value: string) {103    setEditingField(field);104    setDraft(value);105    setShowGenerator(false);106    setConfirmDelete(false);107  }108109  async function saveField() {110    const field = editingField();111    const entry = props.entry;112    if (!field || !entry) return;113    // Don't trim passwords — spaces may be intentional114    let value = field === "password" ? draft() : draft().trim();115    if (field === "totp" && value.startsWith("otpauth://")) {116      const parsed = parseOtpauthURI(value);117      value = parsed?.secret ?? value;118    }119    if (field === "password" && entry.type !== "note" && !value) {120      return;121    }122    setSaving(true);123    try {124      await sendMessage({125        type: "UPDATE_ENTRY",126        payload: { id: entry.id, patch: { [field]: value } },127      });128      triggerRing();129      setEditingField(null);130      setShowGenerator(false);131      await props.onFieldSaved();132    } catch {133      // silent — leave editing open134    } finally {135      setSaving(false);136    }137  }138139  function cancelEdit() {140    setEditingField(null);141    setDraft("");142    setShowGenerator(false);143  }144145  async function saveNote(html: string) {146    const entry = props.entry;147    if (!entry) return;148    setSaving(true);149    try {150      await sendMessage({151        type: "UPDATE_ENTRY",152        payload: { id: entry.id, patch: { notes: html } },153      });154      triggerRing();155      setNoteEditing(false);156      await props.onFieldSaved();157    } catch {158      // silent — leave editor open159    } finally {160      setSaving(false);161    }162  }163164  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {165    switch (field) {166      case "title":167        return entry.title;168      case "url":169        return entry.url ?? "";170      case "username":171        return entry.username ?? "";172      case "password":173        return entry.password ?? "";174      case "notes":175        return entry.notes ?? "";176      case "totp":177        return entry.totp ?? "";178    }179  }180181  function handleEditorFocusOut(182    field: Exclude<EditableField, null>,183    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }184  ) {185    const nextTarget = event.relatedTarget;186    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;187188    const entry = props.entry;189    if (!entry) return;190191    const currentValue = fieldValue(entry, field);192    const nextValue = field === "password" ? draft() : draft().trim();193    if (nextValue === currentValue) {194      cancelEdit();195    }196  }197198  async function savePassword(password: string) {199    const entry = props.entry;200    if (!entry) return;201    setSaving(true);202    try {203      await sendMessage({204        type: "UPDATE_ENTRY",205        payload: { id: entry.id, patch: { password } },206      });207      triggerRing();208      setShowGenerator(false);209      await props.onFieldSaved();210    } catch {211      // silent212    } finally {213      setSaving(false);214    }215  }216217  // ── Copy ─────────────────────────────────────────────────────────218219  async function copyField(text: string, label: string, button?: HTMLButtonElement) {220    const response = await sendMessage({ type: "COPY", payload: { text, label } });221    if ((response as { ok: boolean }).ok) {222      const entry = props.entry;223      if (entry?.canary?.webhookUrl) {224        const trigger =225          label === "password"226            ? "password_copied"227            : label === "username"228              ? "username_copied"229              : undefined;230        if (trigger) {231          void sendMessage({232            type: "FIRE_CANARY_PING",233            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },234          });235        }236      }237      triggerRing();238      triggerPipPulse();239      setCopied(null);240      requestAnimationFrame(() => setCopied(label));241      if (button) {242        button.classList.remove("is-copied", "is-retreat");243        void button.offsetWidth;244        const drain = document.createElement("span");245        drain.className = "peach-copy-drain";246        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";247        drain.appendChild(bar);248        button.appendChild(drain);249        window.setTimeout(() => {250          button.classList.remove("is-copied");251          button.classList.add("is-retreat");252          drain.remove();253          window.setTimeout(() => button.classList.remove("is-retreat"), 160);254        }, 1800);255      }256      window.setTimeout(() => setCopied(null), 1800);257    }258  }259260  // ── Keyboard shortcuts ────────────────────────────────────────────261262  createEffect(() => {263    if (!props.entry) return;264    const handler = (e: KeyboardEvent) => {265      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;266      if (inInput) return;267      switch (e.key) {268        case "c": e.preventDefault();269          if (props.entry?.password) void copyField(props.entry.password, "password"); break;270        case "u": e.preventDefault();271          if (props.entry?.username) void copyField(props.entry.username, "username"); break;272        case "Escape": e.preventDefault(); props.onEscape?.(); break;273      }274    };275    window.addEventListener("keydown", handler);276    onCleanup(() => window.removeEventListener("keydown", handler));277  });278279  // ── Input ref focus helper ────────────────────────────────────────280281  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {282    requestAnimationFrame(() => { el.focus(); el.select?.(); });283  }284285  function onEditKeyDown(e: KeyboardEvent) {286    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {287      e.preventDefault();288      void saveField();289    }290    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }291  }292293  return (294    <>295      <div296        class="peach-ghost"297        aria-hidden="true"298        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;"299      >300        {props.entry?.title || "empty"}301      </div>302303      <div class="peach-stage" style="position:relative;">304        <Show when={props.entry} fallback={<EmptyVaultState />}>305          {(entry) => (306            <>307              {/* ── Breach strip ─────────────────────────────────── */}308              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>309                <div class="peach-breach-badge">310                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">311                    <path d="M6 1L11 10H1L6 1z" />312                    <line x1="6" y1="5" x2="6" y2="7.5" />313                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />314                  </svg>315                  <span style="flex:1">found in a known data breach</span>316                  <button317                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"318                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}319                  >320                    Change password321                  </button>322                </div>323              </Show>324325              {/* ── Title + trash ─────────────────────────────────── */}326              <div class="peach-entry-header">327                <div class="peach-entry-header-name">328                  <Show329                    when={editingField() === "title"}330                    fallback={331                      <button332                        class="peach-entry-name-button"333                        title="Click to edit title"334                        onClick={() => startEdit("title", entry().title)}335                      >336                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>337                          <span class="peach-entry-name-text">338                            <Show339                              when={useAnimatedTitle()}340                              fallback={entry().title}341                            >342                              <For each={entry().title.split("")}>343                                {(ch, i) => (344                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>345                                    {ch === " " ? "\u00A0" : ch}346                                  </span>347                                )}348                              </For>349                            </Show>350                          </span>351                        </h1>352                      </button>353                    }354                  >355                    <div356                      class="peach-inline-title-wrap"357                      onFocusOut={(event) => handleEditorFocusOut("title", event)}358                    >359                      <input360                        class="peach-inline-title-input"361                        value={draft()}362                        onInput={(e) => setDraft(e.currentTarget.value)}363                        onKeyDown={onEditKeyDown}364                        ref={autoFocus}365                        disabled={saving()}366                      />367                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />368                    </div>369                  </Show>370                </div>371372                {/* Subtle trash icon — hidden while editing title */}373                <Show when={editingField() !== "title"}>374                  <Show375                    when={confirmDelete()}376                    fallback={377                      <button378                        class="peach-entry-trash-btn"379                        onClick={() => setConfirmDelete(true)}380                        aria-label="Move to trash"381                        title="Delete entry"382                      >383                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">384                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />385                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />386                        </svg>387                      </button>388                    }389                  >390                    <div class="peach-entry-trash-confirm">391                      <span class="peach-entry-trash-confirm-label">trash?</span>392                      <button393                        class="peach-btn peach-btn--sm peach-btn--danger"394                        style="font-size:10px;padding:2px 8px;min-height:22px;"395                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}396                      >397                        yes398                      </button>399                      <button400                        class="peach-btn peach-btn--ghost peach-btn--sm"401                        style="font-size:10px;padding:2px 8px;min-height:22px;"402                        onClick={() => setConfirmDelete(false)}403                      >404                        no405                      </button>406                    </div>407                  </Show>408                </Show>409              </div>410411              {/* ── Domain / URL ─────────────────────────────────── */}412              <Show when={entry().type !== "note"}>413                <div class="peach-domain" style="position:relative;z-index:1;">414                  <Show415                    when={editingField() === "url"}416                    fallback={417                      <div418                        class="peach-domain-row peach-inline-clickable"419                        title="Click to edit URL"420                        onClick={() => startEdit("url", entry().url ?? "")}421                      >422                        <Show when={faviconSrc()}>423                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />424                        </Show>425                        <span class="peach-domain-value">426                          {displayDomain() || (entry().url ? entry().url : "add a website url")}427                        </span>428                        <span class="peach-inline-edit-hint">edit</span>429                      </div>430                    }431                  >432                    <div433                      class="peach-inline-field-wrap"434                      onFocusOut={(event) => handleEditorFocusOut("url", event)}435                    >436                      <input437                        class="peach-inline-input"438                        type="url"439                        placeholder="https://"440                        value={draft()}441                        onInput={(e) => setDraft(e.currentTarget.value)}442                        onKeyDown={onEditKeyDown}443                        ref={autoFocus}444                        disabled={saving()}445                      />446                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />447                    </div>448                  </Show>449                </div>450              </Show>451452              {/* ── Tags ─────────────────────────────────────────── */}453              <Show when={entry().tags && entry().tags!.length > 0}>454                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">455                  <For each={entry().tags!}>456                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}457                  </For>458                </div>459              </Show>460461              {/* ── Note content ─────────────────────────────────── */}462              <Show when={entry().type === "note"}>463                <section class="peach-fields" style="position:relative;z-index:1;">464                  <Show465                    when={entry().notes}466                    fallback={467                      <div468                        class="peach-note-content peach-inline-clickable"469                        style="cursor:text;"470                        onClick={() => setNoteEditing(true)}471                      >472                        <span style="color:var(--peach-t2);font-style:italic;">click to add notes</span>473                      </div>474                    }475                  >476                    <div477                      class="peach-note-content peach-inline-clickable"478                      innerHTML={entry().notes}479                      onClick={() => setNoteEditing(true)}480                    />481                  </Show>482                </section>483              </Show>484485              {/* ── Login fields ─────────────────────────────────── */}486              <Show when={entry().type !== "note"}>487                <section class="peach-fields" style="position:relative;z-index:1;">488489                  {/* Username */}490                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>491                    <span class="peach-label">username</span>492                    <Show493                      when={editingField() === "username"}494                      fallback={495                        <div class="peach-field-value-row">496                          <span497                            class="peach-field-value peach-inline-clickable"498                            title="Click to edit"499                            onClick={() => startEdit("username", entry().username ?? "")}500                          >501                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}502                          </span>503                          <Show when={entry().username}>504                            <button505                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}506                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}507                              aria-label="Copy username"508                            >509                              <CopyGlyph /><span class="peach-copy-sheen" />510                            </button>511                          </Show>512                        </div>513                      }514                    >515                      <div516                        class="peach-inline-field-wrap"517                        onFocusOut={(event) => handleEditorFocusOut("username", event)}518                      >519                        <input520                          class="peach-inline-input"521                          value={draft()}522                          onInput={(e) => setDraft(e.currentTarget.value)}523                          onKeyDown={onEditKeyDown}524                          ref={autoFocus}525                          disabled={saving()}526                          autocomplete="off"527                        />528                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />529                      </div>530                    </Show>531                  </div>532533                  {/* Password */}534                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>535                    <span class="peach-label">password</span>536                    <Show537                      when={editingField() === "password"}538                      fallback={539                        <div class="peach-field-value-row">540                          <span541                            class="peach-password-value"542                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}543                            style="cursor:pointer;"544                            title="Click to edit password"545                            onClick={() => startEdit("password", entry().password ?? "")}546                          >547                            <Show548                              when={showPassword()}549                              fallback={<span class="peach-password-mask">...............</span>}550                            >551                              <span class="peach-password-text">{entry().password ?? ""}</span>552                            </Show>553                          </span>554                          <div class="peach-field-actions">555                            <button556                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"557                              onClick={() => setShowPassword(!showPassword())}558                              aria-label="Reveal password"559                            >560                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>561                                <EyeClosedGlyph />562                              </Show>563                            </button>564                            <button565                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}566                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}567                              aria-label="Copy password"568                            >569                              <CopyGlyph /><span class="peach-copy-sheen" />570                            </button>571                          </div>572                        </div>573                      }574                    >575                      {/* ── Edit mode: type / paste / generate ── */}576                      <div577                        class="peach-inline-field-wrap"578                        onFocusOut={(event) => handleEditorFocusOut("password", event)}579                      >580                        <div class="peach-password-edit-row">581                          <input582                            class="peach-inline-input"583                            type={showPassword() ? "text" : "password"}584                            placeholder="paste or type a password"585                            value={draft()}586                            onInput={(e) => setDraft(e.currentTarget.value)}587                            onKeyDown={(e) => {588                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }589                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }590                            }}591                            ref={autoFocus}592                            disabled={saving()}593                            autocomplete="new-password"594                          />595                          <button596                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"597                            type="button"598                            onClick={() => setShowPassword(!showPassword())}599                            aria-label={showPassword() ? "Hide" : "Show"}600                          >601                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>602                              <EyeClosedGlyph />603                            </Show>604                          </button>605                        </div>606                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />607                        <button608                          class="peach-generate-toggle-link"609                          type="button"610                          onClick={() => setShowGenerator(!showGenerator())}611                        >612                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">613                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />614                            <path d="M9.5 2.5H11V1" />615                          </svg>616                          {showGenerator() ? "hide generator" : "generate"}617                        </button>618                        <Show when={showGenerator()}>619                          <GeneratorDrawer620                            open={true}621                            compact={true}622                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}623                          />624                        </Show>625                      </div>626                    </Show>627                  </div>628629                </section>630              </Show>631632              {/* ── Notes (on login entries) ──────────────────────── */}633              <Show when={entry().type !== "note"}>634                <Show635                  when={entry().notes || editingField() === "notes"}636                  fallback={637                    <button638                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"639                      onClick={() => startEdit("notes", "")}640                    >641                      + add notes642                    </button>643                  }644                >645                  <div style="position:relative;z-index:1;margin-top:6px;">646                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>647                    <Show648                      when={editingField() === "notes"}649                      fallback={650                        <div651                          class="peach-note-content peach-inline-clickable"652                          style="cursor:text;white-space:pre-wrap;"653                          onClick={() => startEdit("notes", entry().notes ?? "")}654                        >655                          {entry().notes}656                        </div>657                      }658                    >659                      <div660                        class="peach-inline-field-wrap"661                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}662                      >663                        <textarea664                          class="peach-inline-textarea"665                          value={draft()}666                          onInput={(e) => setDraft(e.currentTarget.value)}667                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}668                          ref={(el) => requestAnimationFrame(() => el?.focus())}669                          rows={4}670                          disabled={saving()}671                        />672                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />673                      </div>674                    </Show>675                  </div>676                </Show>677              </Show>678679              {/* ── TOTP ─────────────────────────────────────────── */}680              <Show when={totpState()}>681                {(state) => (682                  <section class="peach-totp-band">683                    <Show684                      when={editingField() === "totp"}685                      fallback={686                        <>687                          <div688                            class="peach-totp-main peach-inline-clickable"689                            onClick={() => startEdit("totp", entry().totp ?? "")}690                          >691                            <span class="peach-label">2-factor</span>692                            <div class="peach-totp-code-row">693                              <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>694                                <span class="peach-totp-code-text">695                                  {state().code.slice(0, 3)} {state().code.slice(3)}696                                </span>697                              </p>698                              <button699                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}700                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}701                                aria-label="Copy TOTP code"702                              >703                                <CopyGlyph /><span class="peach-copy-sheen" />704                              </button>705                            </div>706                          </div>707                          <div class="peach-totp-meta">708                            <span class="peach-label">expires</span>709                            <div class="peach-totp-countdown">{state().remaining}s</div>710                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">711                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />712                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"713                                stroke-dasharray="106.8"714                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}715                              />716                            </svg>717                          </div>718                        </>719                      }720                    >721                      <div722                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"723                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}724                      >725                        <span class="peach-label">2-factor</span>726                        <input727                          class="peach-inline-input"728                          value={draft()}729                          onInput={(e) => setDraft(e.currentTarget.value)}730                          onKeyDown={onEditKeyDown}731                          ref={autoFocus}732                          disabled={saving()}733                          placeholder="otpauth://... or base32 secret"734                          autocomplete="off"735                        />736                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />737                      </div>738                    </Show>739                  </section>740                )}741              </Show>742743              {/* ── Bottom spacer ─────────────────────────────────── */}744              <div style="height:4px;" />745            </>746          )}747        </Show>748      </div>749750      <Show when={noteEditing()}>751        <NoteEditor752          initialContent={props.entry?.notes ?? ""}753          saving={saving()}754          onSave={saveNote}755          onCancel={() => setNoteEditing(false)}756        />757      </Show>758    </>759  );760}761762// ── Sub-components ────────────────────────────────────────────────────763764function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {765  return (766    <div class="peach-inline-actions">767      <button768        class="peach-inline-save-btn"769        onClick={props.onSave}770        disabled={props.saving}771        aria-label="Save"772        title="Save (Enter)"773      >774        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">775          <path d="M1 4.5L4 7.5L10 1.5" />776        </svg>777      </button>778      <button779        class="peach-inline-cancel-btn"780        onClick={props.onCancel}781        disabled={props.saving}782        aria-label="Cancel"783        title="Cancel (Esc)"784      >785        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">786          <path d="M1 1L8 8M8 1L1 8" />787        </svg>788      </button>789    </div>790  );791}792793function EmptyVaultState() {794  return (795    <>796      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">797        <div class="peach-entry-name">798          <span class="peach-entry-name-text">799            <span class="peach-entry-name-char" style="--char-index:0">e</span>800            <span class="peach-entry-name-char" style="--char-index:1">m</span>801            <span class="peach-entry-name-char" style="--char-index:2">p</span>802            <span class="peach-entry-name-char" style="--char-index:3">t</span>803            <span class="peach-entry-name-char" style="--char-index:4">y</span>804          </span>805        </div>806      </div>807      <div class="peach-empty-copy">808        <span class="peach-label">vault</span>809        <p>Your vault is ready for its first entry.</p>810      </div>811    </>812  );813}814815function CopyGlyph() {816  return (817    <span class="peach-copy-icon">818      <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">819        <rect x="4" y="4" width="8" height="9" rx="1.5" />820        <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" />821      </svg>822    </span>823  );824}825826function EyeClosedGlyph() {827  return (828    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">829      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />830      <path d="M3.5 5.5L2.5 7.5" />831      <path d="M7 6.5V8.5" />832      <path d="M10.5 5.5L11.5 7.5" />833    </svg>834  );835}836837function EyeOpenGlyph() {838  return (839    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">840      <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" />841      <circle cx="7" cy="5" r="1.75" />842    </svg>843  );844}845846function ChevronGlyph(props: { open: boolean }) {847  return (848    <svg849      width="8"850      height="8"851      viewBox="0 0 8 8"852      fill="none"853      stroke="currentColor"854      stroke-width="1.3"855      stroke-linecap="round"856      stroke-linejoin="round"857      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}858    >859      <path d="M2 1L6 4L2 7" />860    </svg>861  );862}863864function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {865  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));866  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");867  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");868  const [testResult, setTestResult] = createSignal<string | null>(null);869  const [testing, setTesting] = createSignal(false);870871  async function save() {872    const url = webhookUrl().trim();873    const nextLabel = label().trim() || undefined;874    const canary = enabled() && url875      ? {876          webhookUrl: url,877          label: nextLabel,878          lastPinged: props.entry.canary?.lastPinged,879          pingCount: props.entry.canary?.pingCount,880        }881      : undefined;882    await sendMessage({883      type: "UPDATE_ENTRY",884      payload: { id: props.entry.id, patch: { canary } },885    });886    await props.onSaved();887  }888889  async function testPing() {890    const url = webhookUrl().trim();891    if (!url) return;892    setTesting(true);893    setTestResult(null);894    const res = await sendMessage({895      type: "FIRE_CANARY_PING",896      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },897    }) as { ok: boolean; error?: string };898    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);899    setTesting(false);900  }901902  return (903    <div class="peach-canary-section">904      <div class="peach-field-value-row">905        <span class="peach-field-value" style="font-size:12px;">canary alert</span>906        <button907          class={`peach-toggle ${enabled() ? "is-on" : ""}`}908          onClick={() => { setEnabled(!enabled()); void save(); }}909          aria-label="Toggle canary"910        >911          <span class="peach-toggle-knob" />912        </button>913      </div>914      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">915        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.916      </p>917      <Show when={enabled()}>918        <div class="peach-inline-field-wrap" style="margin-top:8px;">919          <input920            class="peach-inline-input"921            placeholder="https://hooks.zapier.com/..."922            value={webhookUrl()}923            onInput={(e) => setWebhookUrl(e.currentTarget.value)}924            onBlur={() => void save()}925          />926          <input927            class="peach-inline-input"928            placeholder="label (optional, e.g. AWS decoy)"929            value={label()}930            onInput={(e) => setLabel(e.currentTarget.value)}931            onBlur={() => void save()}932          />933          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">934            <button935              class="peach-btn peach-btn--ghost peach-btn--sm"936              onClick={() => void testPing()}937              disabled={testing() || !webhookUrl().trim()}938            >939              {testing() ? "sending..." : "test ping"}940            </button>941            <Show when={testResult()}>942              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>943            </Show>944          </div>945          <Show when={props.entry.canary?.lastPinged}>946            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">947              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}948              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts949            </p>950          </Show>951        </div>952      </Show>953    </div>954  );955}