Entry.tsx

39.4 KB · raw

1import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl } from "../../shared/favicon";5import { generateTOTP, parseOtpauthURI } from "../../crypto/totp";6import { sendMessage, triggerPipPulse, triggerRing } from "../App";7import { GeneratorDrawer } from "../components/GeneratorDrawer";8import { NoteEditorInline, Toolbar } from "./NoteEditor";910type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;1112interface EntryProps {13  entry: VaultEntry | null;14  onDelete: () => void;15  /** Called after any inline save so App can reload entries */16  onFieldSaved: () => void | Promise<void>;17  onEscape?: () => void;18}1920export function EntryView(props: EntryProps) {21  const [editingField, setEditingField] = createSignal<EditableField>(null);22  const [draft, setDraft] = createSignal("");23  const [saving, setSaving] = createSignal(false);24  const [showPassword, setShowPassword] = createSignal(false);25  const [showGenerator, setShowGenerator] = createSignal(false);26  const [copied, setCopied] = createSignal<string | null>(null);27  const [totpTick, setTotpTick] = createSignal(0);28  const [confirmDelete, setConfirmDelete] = createSignal(false);29  const [totpHot, setTotpHot] = createSignal(false);30  const [faviconSrc, setFaviconSrc] = createSignal<string | null>(null);31  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);32  let lastSavedNote: string | null = null;3334  function noteExec(cmd: string, val?: string) {35    document.execCommand(cmd, false, val);36    noteEditorEl()?.focus();37  }3839  async function saveNoteInline(html: string) {40    const entry = props.entry;41    if (!entry) return;42    const currentStored = entry.notes ?? "";43    if (html === currentStored) return;44    if (lastSavedNote !== null && html === lastSavedNote) return;45    lastSavedNote = html;46    setSaving(true);47    try {48      await sendMessage({49        type: "UPDATE_ENTRY",50        payload: { id: entry.id, patch: { notes: html } },51      });52      triggerRing();53      await props.onFieldSaved();54    } catch {55      lastSavedNote = null;56    } finally {57      setSaving(false);58    }59  }6061  const interval = window.setInterval(() => setTotpTick((n) => n + 1), 1000);62  onCleanup(() => window.clearInterval(interval));6364  const displayDomain = createMemo(() =>65    props.entry?.url ? getDisplayDomain(props.entry.url) : ""66  );6768  createEffect(() => {69    const domain = displayDomain();70    setFaviconSrc(domain ? faviconUrl(domain) : null);71  });7273  const totpState = createMemo(() => {74    void totpTick();75    if (!props.entry?.totp) return null;76    return generateTOTP(props.entry.totp);77  });7879  let prevTotpCode: string | undefined;80  createEffect(() => {81    const state = totpState();82    if (!state) { prevTotpCode = undefined; return; }83    if (prevTotpCode !== undefined && state.code !== prevTotpCode) {84      setTotpHot(true);85      window.setTimeout(() => setTotpHot(false), 3000);86    }87    prevTotpCode = state.code;88  });8990  const totpProgress = createMemo(() => {91    const state = totpState();92    if (!state) return 0;93    return ((state.remaining - 1) / 29) * 100;94  });9596  createEffect(() => {97    const shell = document.getElementById("hd-shell");98    if (!shell) return;99100    const state = totpState();101    if (!state) {102      delete shell.dataset.totpActive;103      shell.style.removeProperty("--peach-totp-progress");104      return;105    }106107    shell.dataset.totpActive = "true";108    shell.style.setProperty(109      "--peach-totp-progress",110      `${Math.max(0, totpProgress())}%`111    );112113    onCleanup(() => {114      delete shell.dataset.totpActive;115      shell.style.removeProperty("--peach-totp-progress");116    });117  });118119  const titleSizeClass = createMemo(() => {120    const length = props.entry?.title.length ?? 0;121    if (length >= 28) return "peach-entry-name--xxl";122    if (length >= 20) return "peach-entry-name--xl";123    if (length >= 14) return "peach-entry-name--lg";124    return "";125  });126  const useAnimatedTitle = createMemo(() => (props.entry?.title.length ?? 0) <= 13);127128  // ── Inline editing ────────────────────────────────────────────────129130  function startEdit(field: EditableField, value: string) {131    setEditingField(field);132    setDraft(value);133    setShowGenerator(false);134    setConfirmDelete(false);135  }136137  async function saveField() {138    const field = editingField();139    const entry = props.entry;140    if (!field || !entry) return;141    // Don't trim passwords — spaces may be intentional142    let value = field === "password" ? draft() : draft().trim();143    if (field === "totp" && value.startsWith("otpauth://")) {144      const parsed = parseOtpauthURI(value);145      value = parsed?.secret ?? value;146    }147    if (field === "password" && entry.type !== "note" && !value) {148      return;149    }150    setSaving(true);151    try {152      await sendMessage({153        type: "UPDATE_ENTRY",154        payload: { id: entry.id, patch: { [field]: value } },155      });156      triggerRing();157      setEditingField(null);158      setShowGenerator(false);159      await props.onFieldSaved();160    } catch {161      // silent — leave editing open162    } finally {163      setSaving(false);164    }165  }166167  function cancelEdit() {168    setEditingField(null);169    setDraft("");170    setShowGenerator(false);171  }172173  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {174    switch (field) {175      case "title":176        return entry.title;177      case "url":178        return entry.url ?? "";179      case "username":180        return entry.username ?? "";181      case "password":182        return entry.password ?? "";183      case "notes":184        return entry.notes ?? "";185      case "totp":186        return entry.totp ?? "";187    }188  }189190  function handleEditorFocusOut(191    field: Exclude<EditableField, null>,192    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }193  ) {194    const nextTarget = event.relatedTarget;195    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;196197    const entry = props.entry;198    if (!entry) return;199200    const currentValue = fieldValue(entry, field);201    const nextValue = field === "password" ? draft() : draft().trim();202    if (nextValue === currentValue) {203      cancelEdit();204    }205  }206207  async function savePassword(password: string) {208    const entry = props.entry;209    if (!entry) return;210    setSaving(true);211    try {212      await sendMessage({213        type: "UPDATE_ENTRY",214        payload: { id: entry.id, patch: { password } },215      });216      triggerRing();217      setShowGenerator(false);218      await props.onFieldSaved();219    } catch {220      // silent221    } finally {222      setSaving(false);223    }224  }225226  // ── Copy ─────────────────────────────────────────────────────────227228  async function copyField(text: string, label: string, button?: HTMLButtonElement) {229    const response = await sendMessage({ type: "COPY", payload: { text, label } });230    if ((response as { ok: boolean }).ok) {231      const entry = props.entry;232      if (entry?.canary?.webhookUrl) {233        const trigger =234          label === "password"235            ? "password_copied"236            : label === "username"237              ? "username_copied"238              : undefined;239        if (trigger) {240          void sendMessage({241            type: "FIRE_CANARY_PING",242            payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },243          });244        }245      }246      triggerRing();247      triggerPipPulse();248      setCopied(null);249      requestAnimationFrame(() => setCopied(label));250      if (button) {251        button.classList.remove("is-copied", "is-retreat");252        void button.offsetWidth;253        const drain = document.createElement("span");254        drain.className = "peach-copy-drain";255        const bar = document.createElement("span"); bar.className = "peach-copy-drain-bar";256        drain.appendChild(bar);257        button.appendChild(drain);258        window.setTimeout(() => {259          button.classList.remove("is-copied");260          button.classList.add("is-retreat");261          drain.remove();262          window.setTimeout(() => button.classList.remove("is-retreat"), 160);263        }, 1800);264      }265      window.setTimeout(() => setCopied(null), 1800);266    }267  }268269  // ── Keyboard shortcuts ────────────────────────────────────────────270271  createEffect(() => {272    if (!props.entry) return;273    const handler = (e: KeyboardEvent) => {274      const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;275      if (inInput) return;276      switch (e.key) {277        case "c": e.preventDefault();278          if (props.entry?.password) void copyField(props.entry.password, "password"); break;279        case "u": e.preventDefault();280          if (props.entry?.username) void copyField(props.entry.username, "username"); break;281        case "Escape": e.preventDefault(); props.onEscape?.(); break;282      }283    };284    window.addEventListener("keydown", handler);285    onCleanup(() => window.removeEventListener("keydown", handler));286  });287288  // ── Input ref focus helper ────────────────────────────────────────289290  function autoFocus(el: HTMLInputElement | HTMLTextAreaElement) {291    requestAnimationFrame(() => { el.focus(); el.select?.(); });292  }293294  function onEditKeyDown(e: KeyboardEvent) {295    if (e.key === "Enter" && !(e.target instanceof HTMLTextAreaElement)) {296      e.preventDefault();297      void saveField();298    }299    if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }300  }301302  return (303    <>304      <div305        class="peach-ghost"306        aria-hidden="true"307        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;"308      >309        {props.entry?.title || "empty"}310      </div>311312      <div class="peach-stage" style="position:relative;">313        <Show when={props.entry} fallback={<EmptyVaultState />}>314          {(entry) => (315            <>316              {/* ── Breach strip ─────────────────────────────────── */}317              <Show when={entry().type !== "note" && (entry().breach ?? 0) > 0}>318                <div class="peach-breach-badge">319                  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--breach)" stroke-width="1.5" stroke-linecap="round">320                    <path d="M6 1L11 10H1L6 1z" />321                    <line x1="6" y1="5" x2="6" y2="7.5" />322                    <circle cx="6" cy="9" r="0.5" fill="var(--breach)" stroke="none" />323                  </svg>324                  <span style="flex:1">found in a known data breach</span>325                  <button326                    class="peach-btn peach-btn--sm peach-btn--accent peach-breach-action"327                    onClick={() => { startEdit("password", entry().password ?? ""); setShowGenerator(true); }}328                  >329                    Change password330                  </button>331                </div>332              </Show>333334              {/* ── Title + trash ─────────────────────────────────── */}335              <div class="peach-entry-header">336                <div class="peach-entry-header-name">337                  <Show338                    when={editingField() === "title"}339                    fallback={340                      <button341                        class="peach-entry-name-button"342                        title="Click to edit title"343                        onClick={() => startEdit("title", entry().title)}344                      >345                        <h1 class={`peach-entry-name ${titleSizeClass()}`} aria-label={entry().title}>346                          <span class="peach-entry-name-text">347                            <Show348                              when={useAnimatedTitle()}349                              fallback={entry().title}350                            >351                              <For each={entry().title.split("")}>352                                {(ch, i) => (353                                  <span class="peach-entry-name-char" style={`--char-index:${i()}`}>354                                    {ch === " " ? "\u00A0" : ch}355                                  </span>356                                )}357                              </For>358                            </Show>359                          </span>360                        </h1>361                      </button>362                    }363                  >364                    <div365                      class="peach-inline-title-wrap"366                      onFocusOut={(event) => handleEditorFocusOut("title", event)}367                    >368                      <input369                        class="peach-inline-title-input"370                        value={draft()}371                        onInput={(e) => setDraft(e.currentTarget.value)}372                        onKeyDown={onEditKeyDown}373                        ref={autoFocus}374                        disabled={saving()}375                      />376                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />377                    </div>378                  </Show>379                </div>380381                {/* Subtle trash icon — hidden while editing title */}382                <Show when={editingField() !== "title"}>383                  <Show384                    when={confirmDelete()}385                    fallback={386                      <button387                        class="peach-entry-trash-btn"388                        onClick={() => setConfirmDelete(true)}389                        aria-label="Move to trash"390                        title="Delete entry"391                      >392                        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">393                          <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />394                          <path d="M4.5 6l.3 3.5M7.5 6l-.3 3.5" />395                        </svg>396                      </button>397                    }398                  >399                    <div class="peach-entry-trash-confirm">400                      <span class="peach-entry-trash-confirm-label">trash?</span>401                      <button402                        class="peach-btn peach-btn--sm peach-btn--danger"403                        style="font-size:10px;padding:2px 8px;min-height:22px;"404                        onClick={() => { setConfirmDelete(false); props.onDelete(); }}405                      >406                        yes407                      </button>408                      <button409                        class="peach-btn peach-btn--ghost peach-btn--sm"410                        style="font-size:10px;padding:2px 8px;min-height:22px;"411                        onClick={() => setConfirmDelete(false)}412                      >413                        no414                      </button>415                    </div>416                  </Show>417                </Show>418              </div>419420              {/* ── Domain / URL ─────────────────────────────────── */}421              <Show when={entry().type !== "note"}>422                <div class="peach-domain" style="position:relative;z-index:1;">423                  <Show424                    when={editingField() === "url"}425                    fallback={426                      <div427                        class="peach-domain-row peach-inline-clickable"428                        title="Click to edit URL"429                        onClick={() => startEdit("url", entry().url ?? "")}430                      >431                        <Show when={faviconSrc()}>432                          <img src={faviconSrc()!} width="16" height="16" alt="" style="border-radius:3px;margin-right:5px;flex-shrink:0;" />433                        </Show>434                        <span class="peach-domain-value">435                          {displayDomain() || (entry().url ? entry().url : "add a website url")}436                        </span>437                        <span class="peach-inline-edit-hint">edit</span>438                      </div>439                    }440                  >441                    <div442                      class="peach-inline-field-wrap"443                      onFocusOut={(event) => handleEditorFocusOut("url", event)}444                    >445                      <input446                        class="peach-inline-input"447                        type="url"448                        placeholder="https://"449                        value={draft()}450                        onInput={(e) => setDraft(e.currentTarget.value)}451                        onKeyDown={onEditKeyDown}452                        ref={autoFocus}453                        disabled={saving()}454                      />455                      <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />456                    </div>457                  </Show>458                </div>459              </Show>460461              {/* ── Tags ─────────────────────────────────────────── */}462              <Show when={entry().tags && entry().tags!.length > 0}>463                <div style="display:flex;flex-wrap:wrap;gap:5px;margin:8px 0;position:relative;z-index:1;">464                  <For each={entry().tags!}>465                    {(tag) => <span class="peach-tag-chip" style="font-size:10px;padding:2px 8px;">{tag}</span>}466                  </For>467                </div>468              </Show>469470              {/* ── Note toolbar + inline editor ─────────────────── */}471              <Show when={entry().type === "note"}>472                <div class="peach-entry-note-toolbar-row">473                  <Toolbar exec={noteExec} class="peach-note-toolbar-inline" />474                </div>475                <section class="peach-fields peach-note-body" style="position:relative;z-index:1;">476                  <NoteEditorInline477                    entryKey={entry().id}478                    initialContent={entry().notes ?? ""}479                    onReady={(el) => { setNoteEditorEl(el); lastSavedNote = null; }}480                    onBlur={saveNoteInline}481                    autoFocus={false}482                  />483                </section>484              </Show>485486              {/* ── Login fields ─────────────────────────────────── */}487              <Show when={entry().type !== "note"}>488                <section class="peach-fields" style="position:relative;z-index:1;">489490                  {/* Username */}491                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "username" }}>492                    <span class="peach-label">username</span>493                    <Show494                      when={editingField() === "username"}495                      fallback={496                        <div class="peach-field-value-row">497                          <span498                            class="peach-field-value peach-inline-clickable"499                            title="Click to edit"500                            onClick={() => startEdit("username", entry().username ?? "")}501                          >502                            {entry().username || <span style="color:var(--t2);font-style:italic;">click to add username</span>}503                          </span>504                          <Show when={entry().username}>505                            <button506                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "username" ? "is-copied" : ""}`}507                              onClick={(e) => void copyField(entry().username!, "username", e.currentTarget as HTMLButtonElement)}508                              aria-label="Copy username"509                            >510                              <CopyGlyph /><span class="peach-copy-sheen" />511                            </button>512                          </Show>513                        </div>514                      }515                    >516                      <div517                        class="peach-inline-field-wrap"518                        onFocusOut={(event) => handleEditorFocusOut("username", event)}519                      >520                        <input521                          class="peach-inline-input"522                          value={draft()}523                          onInput={(e) => setDraft(e.currentTarget.value)}524                          onKeyDown={onEditKeyDown}525                          ref={autoFocus}526                          disabled={saving()}527                          autocomplete="off"528                        />529                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />530                      </div>531                    </Show>532                  </div>533534                  {/* Password */}535                  <div class="peach-field-card" classList={{ "is-editing": editingField() === "password" }}>536                    <span class="peach-label">password</span>537                    <Show538                      when={editingField() === "password"}539                      fallback={540                        <div class="peach-field-value-row">541                          <span542                            class="peach-password-value"543                            classList={{ "is-revealed": showPassword(), "is-breached": (entry().breach ?? 0) > 0 }}544                            style="cursor:pointer;"545                            title="Click to edit password"546                            onClick={() => startEdit("password", entry().password ?? "")}547                          >548                            <Show549                              when={showPassword()}550                              fallback={<span class="peach-password-mask">...............</span>}551                            >552                              <span class="peach-password-text">{entry().password ?? ""}</span>553                            </Show>554                          </span>555                          <div class="peach-field-actions">556                            <button557                              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"558                              onClick={() => setShowPassword(!showPassword())}559                              aria-label="Reveal password"560                            >561                              <Show when={showPassword()} fallback={<EyeOpenGlyph />}>562                                <EyeClosedGlyph />563                              </Show>564                            </button>565                            <button566                              class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn ${copied() === "password" ? "is-copied" : ""}`}567                              onClick={(e) => { if (entry().password) void copyField(entry().password!, "password", e.currentTarget as HTMLButtonElement); }}568                              aria-label="Copy password"569                            >570                              <CopyGlyph /><span class="peach-copy-sheen" />571                            </button>572                          </div>573                        </div>574                      }575                    >576                      {/* ── Edit mode: type / paste / generate ── */}577                      <div578                        class="peach-inline-field-wrap"579                        onFocusOut={(event) => handleEditorFocusOut("password", event)}580                      >581                        <div class="peach-password-edit-row">582                          <input583                            class="peach-inline-input"584                            type={showPassword() ? "text" : "password"}585                            placeholder="paste or type a password"586                            value={draft()}587                            onInput={(e) => setDraft(e.currentTarget.value)}588                            onKeyDown={(e) => {589                              if (e.key === "Enter") { e.preventDefault(); void saveField(); }590                              if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }591                            }}592                            ref={autoFocus}593                            disabled={saving()}594                            autocomplete="new-password"595                          />596                          <button597                            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"598                            type="button"599                            onClick={() => setShowPassword(!showPassword())}600                            aria-label={showPassword() ? "Hide" : "Show"}601                          >602                            <Show when={showPassword()} fallback={<EyeOpenGlyph />}>603                              <EyeClosedGlyph />604                            </Show>605                          </button>606                        </div>607                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />608                        <button609                          class="peach-generate-toggle-link"610                          type="button"611                          onClick={() => setShowGenerator(!showGenerator())}612                        >613                          <svg width="10" height="10" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">614                            <path d="M11 2.5A5.5 5.5 0 1 0 12.5 7" />615                            <path d="M9.5 2.5H11V1" />616                          </svg>617                          {showGenerator() ? "hide generator" : "generate"}618                        </button>619                        <Show when={showGenerator()}>620                          <GeneratorDrawer621                            open={true}622                            compact={true}623                            onUse={(pw) => { setDraft(pw); setShowGenerator(false); }}624                          />625                        </Show>626                      </div>627                    </Show>628                  </div>629630                </section>631              </Show>632633              {/* ── Notes (on login entries) ──────────────────────── */}634              <Show when={entry().type !== "note"}>635                <Show636                  when={entry().notes || editingField() === "notes"}637                  fallback={638                    <button639                      class="peach-btn peach-btn--ghost peach-btn--sm peach-add-notes-btn"640                      onClick={() => startEdit("notes", "")}641                    >642                      + add notes643                    </button>644                  }645                >646                  <div style="position:relative;z-index:1;margin-top:6px;">647                    <span class="peach-label" style="display:block;margin-bottom:4px;">notes</span>648                    <Show649                      when={editingField() === "notes"}650                      fallback={651                        <div652                          class="peach-note-content peach-inline-clickable"653                          style="cursor:text;white-space:pre-wrap;"654                          onClick={() => startEdit("notes", entry().notes ?? "")}655                        >656                          {entry().notes}657                        </div>658                      }659                    >660                      <div661                        class="peach-inline-field-wrap"662                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}663                      >664                        <textarea665                          class="peach-inline-textarea"666                          value={draft()}667                          onInput={(e) => setDraft(e.currentTarget.value)}668                          onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } }}669                          ref={(el) => requestAnimationFrame(() => el?.focus())}670                          rows={4}671                          disabled={saving()}672                        />673                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />674                      </div>675                    </Show>676                  </div>677                </Show>678              </Show>679680              {/* ── TOTP ─────────────────────────────────────────── */}681              <Show when={totpState()}>682                {(state) => (683                  <section class="peach-totp-band">684                    <Show685                      when={editingField() === "totp"}686                      fallback={687                        <>688                          <div689                            class="peach-totp-main peach-inline-clickable"690                            onClick={() => startEdit("totp", entry().totp ?? "")}691                          >692                            <span class="peach-label">2-factor</span>693                            <div class="peach-totp-code-row">694                              <p class={`peach-totp-code${totpHot() ? " is-hot" : ""}`}>695                                <span class="peach-totp-code-text">696                                  {state().code.slice(0, 3)} {state().code.slice(3)}697                                </span>698                              </p>699                              <button700                                class={`peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-copy-btn peach-totp-copy-btn ${copied() === "totp" ? "is-copied" : ""}`}701                                onClick={(e) => void copyField(state().code.replace(" ", ""), "totp", e.currentTarget as HTMLButtonElement)}702                                aria-label="Copy TOTP code"703                              >704                                <CopyGlyph /><span class="peach-copy-sheen" />705                              </button>706                            </div>707                          </div>708                          <div class="peach-totp-meta">709                            <span class="peach-label">expires</span>710                            <div class="peach-totp-countdown">{state().remaining}s</div>711                            <svg class="peach-totp-ring" width="44" height="44" viewBox="0 0 44 44">712                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-track" />713                              <circle cx="22" cy="22" r="17" class="peach-totp-ring-progress"714                                stroke-dasharray="106.8"715                                stroke-dashoffset={(1 - state().remaining / 30) * 106.8}716                              />717                            </svg>718                          </div>719                        </>720                      }721                    >722                      <div723                        class="peach-inline-field-wrap peach-inline-field-wrap--totp"724                        onFocusOut={(event) => handleEditorFocusOut("totp", event)}725                      >726                        <span class="peach-label">2-factor</span>727                        <input728                          class="peach-inline-input"729                          value={draft()}730                          onInput={(e) => setDraft(e.currentTarget.value)}731                          onKeyDown={onEditKeyDown}732                          ref={autoFocus}733                          disabled={saving()}734                          placeholder="otpauth://... or base32 secret"735                          autocomplete="off"736                        />737                        <InlineSaveCancelRow onSave={saveField} onCancel={cancelEdit} saving={saving()} />738                      </div>739                    </Show>740                  </section>741                )}742              </Show>743744              {/* ── Bottom spacer ─────────────────────────────────── */}745              <div style="height:4px;" />746            </>747          )}748        </Show>749      </div>750751    </>752  );753}754755// ── Sub-components ────────────────────────────────────────────────────756757function InlineSaveCancelRow(props: { onSave: () => void; onCancel: () => void; saving: boolean }) {758  return (759    <div class="peach-inline-actions">760      <button761        class="peach-inline-save-btn"762        onClick={props.onSave}763        disabled={props.saving}764        aria-label="Save"765        title="Save (Enter)"766      >767        <svg width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">768          <path d="M1 4.5L4 7.5L10 1.5" />769        </svg>770      </button>771      <button772        class="peach-inline-cancel-btn"773        onClick={props.onCancel}774        disabled={props.saving}775        aria-label="Cancel"776        title="Cancel (Esc)"777      >778        <svg width="9" height="9" viewBox="0 0 9 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">779          <path d="M1 1L8 8M8 1L1 8" />780        </svg>781      </button>782    </div>783  );784}785786function EmptyVaultState() {787  return (788    <>789      <div class="peach-entry-name-wrap" style="position:relative;z-index:1;">790        <div class="peach-entry-name">791          <span class="peach-entry-name-text">792            <span class="peach-entry-name-char" style="--char-index:0">e</span>793            <span class="peach-entry-name-char" style="--char-index:1">m</span>794            <span class="peach-entry-name-char" style="--char-index:2">p</span>795            <span class="peach-entry-name-char" style="--char-index:3">t</span>796            <span class="peach-entry-name-char" style="--char-index:4">y</span>797          </span>798        </div>799      </div>800      <div class="peach-empty-copy">801        <span class="peach-label">vault</span>802        <p>Your vault is ready for its first entry.</p>803      </div>804    </>805  );806}807808function CopyGlyph() {809  return (810    <span class="peach-copy-icon">811      <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">812        <rect x="4" y="4" width="8" height="9" rx="1.5" />813        <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" />814      </svg>815    </span>816  );817}818819function EyeClosedGlyph() {820  return (821    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">822      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />823      <path d="M3.5 5.5L2.5 7.5" />824      <path d="M7 6.5V8.5" />825      <path d="M10.5 5.5L11.5 7.5" />826    </svg>827  );828}829830function EyeOpenGlyph() {831  return (832    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">833      <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" />834      <circle cx="7" cy="5" r="1.75" />835    </svg>836  );837}838839function ChevronGlyph(props: { open: boolean }) {840  return (841    <svg842      width="8"843      height="8"844      viewBox="0 0 8 8"845      fill="none"846      stroke="currentColor"847      stroke-width="1.3"848      stroke-linecap="round"849      stroke-linejoin="round"850      style={`transform: rotate(${props.open ? 90 : 0}deg); transition: transform 160ms ease;`}851    >852      <path d="M2 1L6 4L2 7" />853    </svg>854  );855}856857function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {858  const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));859  const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");860  const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");861  const [testResult, setTestResult] = createSignal<string | null>(null);862  const [testing, setTesting] = createSignal(false);863864  async function save() {865    const url = webhookUrl().trim();866    const nextLabel = label().trim() || undefined;867    const canary = enabled() && url868      ? {869          webhookUrl: url,870          label: nextLabel,871          lastPinged: props.entry.canary?.lastPinged,872          pingCount: props.entry.canary?.pingCount,873        }874      : undefined;875    await sendMessage({876      type: "UPDATE_ENTRY",877      payload: { id: props.entry.id, patch: { canary } },878    });879    await props.onSaved();880  }881882  async function testPing() {883    const url = webhookUrl().trim();884    if (!url) return;885    setTesting(true);886    setTestResult(null);887    const res = await sendMessage({888      type: "FIRE_CANARY_PING",889      payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },890    }) as { ok: boolean; error?: string };891    setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);892    setTesting(false);893  }894895  return (896    <div class="peach-canary-section">897      <div class="peach-field-value-row">898        <span class="peach-field-value" style="font-size:12px;">canary alert</span>899        <button900          class={`peach-toggle ${enabled() ? "is-on" : ""}`}901          onClick={() => { setEnabled(!enabled()); void save(); }}902          aria-label="Toggle canary"903        >904          <span class="peach-toggle-knob" />905        </button>906      </div>907      <p style="font-size:10px;color:var(--peach-t2);margin:4px 0 0;line-height:1.5;">908        Fires a silent webhook when this entry's password or username is copied. Great for honeypot credentials.909      </p>910      <Show when={enabled()}>911        <div class="peach-inline-field-wrap" style="margin-top:8px;">912          <input913            class="peach-inline-input"914            placeholder="https://hooks.zapier.com/..."915            value={webhookUrl()}916            onInput={(e) => setWebhookUrl(e.currentTarget.value)}917            onBlur={() => void save()}918          />919          <input920            class="peach-inline-input"921            placeholder="label (optional, e.g. AWS decoy)"922            value={label()}923            onInput={(e) => setLabel(e.currentTarget.value)}924            onBlur={() => void save()}925          />926          <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">927            <button928              class="peach-btn peach-btn--ghost peach-btn--sm"929              onClick={() => void testPing()}930              disabled={testing() || !webhookUrl().trim()}931            >932              {testing() ? "sending..." : "test ping"}933            </button>934            <Show when={testResult()}>935              <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>936            </Show>937          </div>938          <Show when={props.entry.canary?.lastPinged}>939            <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">940              Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}941              {" · "}{props.entry.canary?.pingCount ?? 0} total alerts942            </p>943          </Show>944        </div>945      </Show>946    </div>947  );948}