Add.tsx

32.5 KB · raw

1import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../App";4import { parseOtpauthURI } from "../../crypto/totp";5import { NoteEditorInline, Toolbar } from "./NoteEditor";6import { GeneratorDrawer } from "../components/GeneratorDrawer";78interface AddProps {9  entry?: VaultEntry;10  entries?: VaultEntry[];11  autoOpenGenerator?: boolean;12  onBack: () => void;13  onSave: (entry?: VaultEntry) => void | Promise<void>;14}1516export function AddView(props: AddProps) {17  const [title, setTitle] = createSignal(props.entry?.title ?? "");18  const [username, setUsername] = createSignal(props.entry?.username ?? "");19  const [password, setPassword] = createSignal(props.entry?.password ?? "");20  const [url, setUrl] = createSignal(props.entry?.url ?? "");21  const [totp, setTotp] = createSignal(props.entry?.totp ?? "");22  const [notes, setNotes] = createSignal(props.entry?.notes ?? "");23  const [saving, setSaving] = createSignal(false);24  const [error, setError] = createSignal("");25  const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);26  const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);27  const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);28  const [tagInput, setTagInput] = createSignal("");29  const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);30  const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);31  const [showPassword, setShowPassword] = createSignal(false);32  const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));33  const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));34  const [email, setEmail] = createSignal((props.entry as VaultEntry & { email?: string })?.email ?? "");35  const [activeIdentityField, setActiveIdentityField] = createSignal<"username" | "email" | null>(null);36  const [currentTabUrl, setCurrentTabUrl] = createSignal("");3738  // Mutable refs for keyboard navigation between inputs and suggestion lists.39  // Not signals — focus state doesn't need to be reactive.40  let usernameInputEl: HTMLInputElement | undefined;41  let emailInputEl: HTMLInputElement | undefined;42  let usernameSuggestEl: HTMLDivElement | undefined;43  let emailSuggestEl: HTMLDivElement | undefined;44  let suppressIdentityOpen = false;4546  function openIdentityField(field: "username" | "email") {47    if (suppressIdentityOpen) { suppressIdentityOpen = false; return; }48    setActiveIdentityField(field);49  }5051  const allIdentities = createMemo(() => {52    const emailMap = new Map<string, number>();53    const usernameMap = new Map<string, number>();54    for (const entry of props.entries ?? []) {55      const uname = entry.username?.trim() ?? "";56      const em = entry.email?.trim() ?? "";57      if (uname) {58        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);59        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);60      }61      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);62    }63    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];64    const seen = new Set<string>();65    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {66      if (seen.has(v)) continue;67      seen.add(v);68      const ec = emailMap.get(v) ?? 0;69      const uc = usernameMap.get(v) ?? 0;70      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });71    }72    return result.sort((a, b) => b.count - a.count);73  });7475  const identitySuggestions = createMemo(() => {76    const field = activeIdentityField();77    if (!field) return [];78    const input = (field === "username" ? username() : email()).toLowerCase();79    return allIdentities()80      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))81      .slice(0, 8);82  });8384  const isNoteEntry = () => props.entry?.type === "note";85  const isCardEntry = () => props.entry?.type === "card";86  const [selectedType, setSelectedType] = createSignal<"login" | "note" | "card">(87    isNoteEntry() ? "note" : isCardEntry() ? "card" : "login"88  );8990  // Card signals91  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");92  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");93  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");94  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");95  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");96  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");97  const [showCvc, setShowCvc] = createSignal(false);98  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);99  const [noteEditorEl, setNoteEditorEl] = createSignal<HTMLDivElement | null>(null);100101  function noteExec(cmd: string, val?: string) {102    document.execCommand(cmd, false, val);103    noteEditorEl()?.focus();104  }105106  function getCardNetwork(num: string): string {107    const n = num.replace(/\s/g, '');108    if (/^4/.test(n)) return 'Visa';109    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';110    if (/^3[47]/.test(n)) return 'Amex';111    if (/^6(?:011|5)/.test(n)) return 'Discover';112    return '';113  }114115  function formatCardNumberDisplay(raw: string, focused: boolean): string {116    const n = raw.replace(/\s/g, '');117    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);118    if (/^3[47]/.test(n)) return n.replace(/(\d{4})(\d{1,6})?(\d{1,5})?/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));119    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();120  }121122  const canSave = () => {123    if (!title().trim()) return false;124    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;125    return true;126  };127128  const allTags = createMemo(() => {129    const tagSet = new Set<string>();130    for (const entry of props.entries ?? []) {131      for (const tag of entry.tags ?? []) tagSet.add(tag);132    }133    return Array.from(tagSet).sort();134  });135136  const tagSuggestions = createMemo(() => {137    const input = tagInput().toLowerCase().trim();138    if (!input) return [];139    return allTags().filter(t =>140      t.toLowerCase().includes(input) && !tags().includes(t)141    ).slice(0, 5);142  });143144  function addTag(tag: string) {145    const n = tag.toLowerCase().trim();146    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;147    setTags([...tags(), n]);148  }149150  function removeTag(tag: string) {151    setTags(tags().filter(t => t !== tag));152  }153154  function handleTagKeyDown(e: KeyboardEvent) {155    if (e.key === "Enter" || e.key === ",") {156      e.preventDefault();157      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }158    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {159      removeTag(tags()[tags().length - 1]);160    }161  }162163  // Strength check164  createEffect(async () => {165    const pwd = password();166    if (pwd.length < 4) { setPasswordStrength(null); return; }167    try {168      if (!zxcvbnFn()) {169        // Replaces the unmaintained `zxcvbn` package (LV-8; see Lock.tsx).170        const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([171          import("@zxcvbn-ts/core"),172          import("@zxcvbn-ts/language-common"),173          import("@zxcvbn-ts/language-en"),174        ]);175        zxcvbnOptions.setOptions({176          translations: en.translations,177          graphs: common.adjacencyGraphs,178          dictionary: { ...common.dictionary, ...en.dictionary },179        });180        setZxcvbnFn(() => (pwd: string) => zxcvbn(pwd));181      }182      const fn = zxcvbnFn();183      if (fn) setPasswordStrength(fn(pwd).score);184    } catch {185      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);186    }187  });188189  onMount(() => {190    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;191    if (el) el.focus();192    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {193      const tabUrl = tabs[0]?.url;194      if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);195    });196    // Keep the vault alive while the form is open — the auto-lock alarm fires on197    // a timer and doesn't know the user is actively filling in a new entry.198    const keepAlive = setInterval(() => {199      chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});200    }, 30_000);201    onCleanup(() => clearInterval(keepAlive));202  });203204  function getNormalizedTotp(): string | undefined {205    const value = totp().trim();206    if (!value) return undefined;207    if (value.startsWith("otpauth://")) {208      const parsed = parseOtpauthURI(value);209      return parsed?.secret ?? value;210    }211    return value;212  }213214  async function handleSave() {215    if (!canSave()) {216      setError(217        selectedType() === "note"218          ? "title is required"219          : selectedType() === "card"220          ? "cardholder name and card number are required"221          : "title is required"222      );223      return;224    }225226    setSaving(true);227    setError("");228229    const payload: Record<string, unknown> = {230      type: selectedType(),231      title: title().trim(),232      notes: notes().trim() || undefined,233      tags: tags().length > 0 ? tags() : undefined,234    };235236    if (selectedType() === "login") {237      payload.username = username().trim();238      payload.email = email().trim() || undefined;239      payload.password = password();240      payload.url = url().trim();241      payload.totp = getNormalizedTotp();242    }243244    if (selectedType() === "card") {245      const raw = cardNumber().replace(/\s/g, '');246      payload.cardholderName = cardholderName().trim();247      payload.cardNumber = raw;248      payload.expMonth = expMonth().trim();249      payload.expYear = expYear().trim();250      payload.cvc = cvc().trim();251      payload.cardNickname = cardNickname().trim() || undefined;252      const n = raw;253      payload.network = /^4/.test(n) ? 'visa' : /^5[1-5]|^2[2-7]/.test(n) ? 'mastercard' : /^3[47]/.test(n) ? 'amex' : /^6(?:011|5)/.test(n) ? 'discover' : 'unknown';254      payload.last4 = raw.slice(-4);255    }256257    if (props.entry) {258      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });259      const result = res as { ok: boolean; error?: string };260      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }261      await props.onSave({ ...props.entry, ...payload } as VaultEntry);262      return;263    }264265    const res = await sendMessage({ type: "ADD_ENTRY", payload });266    const result = res as { ok: boolean; data?: VaultEntry; error?: string };267    if (!result.ok) {268      if (result.error === "vault_locked") {269        setError("vault is locked — tap the lock icon to re-enter your master password");270      } else {271        setError(result.error ?? "could not save entry");272      }273      setSaving(false);274      return;275    }276    await props.onSave(result.data);277  }278279  const strengthLabel = () => {280    const s = passwordStrength();281    if (s === null) return null;282    return ["weak", "weak", "fair", "good", "strong"][s] as string;283  };284285  return (286    <div class="peach-overlay-view ov-enter-right" style="display:flex">287      <div class="peach-overlay-header">288        <button289          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"290          onClick={props.onBack}291        >292          cancel293        </button>294        <span class="peach-overlay-title">295          {props.entry ? "edit entry" : "new entry"}296        </span>297        <button298          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"299          onClick={() => void handleSave()}300          disabled={saving() || !canSave()}301        >302          {saving() ? "saving…" : props.entry ? "save" : "create"}303        </button>304      </div>305306      {/* ── Body — mirrors Entry.tsx field-card structure ── */}307      <div class="peach-entry-body peach-entry-body--new">308309        {/* Type toggle */}310        <Show when={!props.entry}>311          <div class="peach-new-type-row">312            <button313              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}314              onClick={() => setSelectedType("login")}315            >login</button>316            <button317              class={`peach-new-type-btn ${selectedType() === "note" ? "is-active" : ""}`}318              onClick={() => setSelectedType("note")}319            >note</button>320            <button321              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}322              onClick={() => setSelectedType("card")}323            >card</button>324          </div>325        </Show>326327        {/* Note formatting toolbar — own row so it never overflows */}328        <Show when={selectedType() === "note"}>329          <Toolbar exec={noteExec} class="peach-note-toolbar-body" />330        </Show>331332        {/* Title */}333        <div class="peach-new-title-wrap">334          <input335            class="peach-new-title-input"336            placeholder={selectedType() === "note" ? "note title" : "entry name"}337            autocomplete="off"338            value={title()}339            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}340            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}341          />342        </div>343344        {/* ── Login fields ── */}345        <Show when={selectedType() === "login"}>346          <div class="peach-domain">347            <div class="peach-domain-edit-row">348              <input349                class="peach-domain-value peach-domain-value--edit"350                placeholder="domain.com"351                autocomplete="off"352                value={url()}353                onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}354              />355              <Show when={currentTabUrl()}>356                <button357                  class="peach-domain-url-btn"358                  type="button"359                  onClick={() => setUrl(currentTabUrl())}360                  title="Insert current page URL"361                >362                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">363                    <path d="M4.5 7.5a3 3 0 0 0 4.243 0L10.5 5.75a3 3 0 0 0-4.243-4.243L5.5 2.26" />364                    <path d="M7.5 4.5a3 3 0 0 0-4.243 0L1.5 6.25a3 3 0 0 0 4.243 4.243L6.5 9.74" />365                  </svg>366                </button>367              </Show>368            </div>369          </div>370371          <section class="peach-fields">372            {/* Username */}373            <div class="peach-field-card">374              <span class="peach-label">username</span>375              <div class="peach-field-value-row">376                <input377                  ref={(el) => (usernameInputEl = el)}378                  class="peach-field-value peach-field-value--edit"379                  placeholder="johndoe"380                  autocomplete="off"381                  value={username()}382                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}383                  onFocus={() => openIdentityField("username")}384                  onBlur={(e) => {385                    if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;386                    setTimeout(() => { if (activeIdentityField() === "username") setActiveIdentityField(null); }, 150);387                  }}388                  onKeyDown={(e) => {389                    if (activeIdentityField() !== "username") return;390                    if (e.key === "ArrowDown") {391                      e.preventDefault();392                      usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();393                    } else if (e.key === "Escape") {394                      e.preventDefault();395                      setActiveIdentityField(null);396                    }397                  }}398                />399              </div>400              <Show when={activeIdentityField() === "username" && identitySuggestions().length > 0}>401                <IdentitySuggestList402                  listRef={(el) => (usernameSuggestEl = el)}403                  inputRef={() => usernameInputEl}404                  suggestions={identitySuggestions()}405                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}406                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}407                />408              </Show>409            </div>410411            {/* Email */}412            <div class="peach-field-card">413              <span class="peach-label">email</span>414              <div class="peach-field-value-row">415                <input416                  ref={(el) => (emailInputEl = el)}417                  class="peach-field-value peach-field-value--edit"418                  type="email"419                  placeholder="[email protected]"420                  autocomplete="off"421                  value={email()}422                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}423                  onFocus={() => openIdentityField("email")}424                  onBlur={(e) => {425                    if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;426                    setTimeout(() => { if (activeIdentityField() === "email") setActiveIdentityField(null); }, 150);427                  }}428                  onKeyDown={(e) => {429                    if (activeIdentityField() !== "email") return;430                    if (e.key === "ArrowDown") {431                      e.preventDefault();432                      emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();433                    } else if (e.key === "Escape") {434                      e.preventDefault();435                      setActiveIdentityField(null);436                    }437                  }}438                />439              </div>440              <Show when={activeIdentityField() === "email" && identitySuggestions().length > 0}>441                <IdentitySuggestList442                  listRef={(el) => (emailSuggestEl = el)}443                  inputRef={() => emailInputEl}444                  suggestions={identitySuggestions()}445                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}446                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}447                />448              </Show>449            </div>450451            {/* Password */}452            <div class="peach-field-card">453              <span class="peach-label">password</span>454              <Show when={!showGenerator()}>455                <div class="peach-password-edit-row">456                  <input457                    class="peach-field-value peach-field-value--edit"458                    type={showPassword() ? "text" : "password"}459                    placeholder="password"460                    autocomplete="new-password"461                    value={password()}462                    onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}463                  />464                  <button465                    class="peach-inline-action peach-password-toggle-btn"466                    type="button"467                    onClick={() => setShowPassword(!showPassword())}468                    aria-label={showPassword() ? "Hide password" : "Show password"}469                  >470                    <Show471                      when={showPassword()}472                      fallback={473                        <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">474                          <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" />475                          <circle cx="7" cy="5" r="1.75" />476                        </svg>477                      }478                    >479                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">480                        <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />481                        <path d="M3.5 5.5L2.5 7.5" />482                        <path d="M7 6.5V8.5" />483                        <path d="M10.5 5.5L11.5 7.5" />484                      </svg>485                    </Show>486                  </button>487                </div>488              </Show>489490              {/* Strength */}491              <Show when={strengthLabel() !== null}>492                <div class="peach-strength-hairline">493                  <div class="peach-strength-hairline-track">494                    <div495                      class="peach-strength-hairline-fill"496                      style={{497                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],498                        background: ["rgba(220,80,60,0.65)","rgba(220,80,60,0.65)","rgba(200,140,60,0.6)","rgba(180,162,100,0.55)","rgba(150,175,110,0.6)"][passwordStrength()!],499                      }}500                    />501                  </div>502                  <span class="peach-strength-label">{strengthLabel()}</span>503                </div>504              </Show>505506              {/* Generator toggle */}507              <button508                class="peach-generate-toggle-link"509                type="button"510                onClick={() => { setShowGenerator(!showGenerator()); }}511              >512                {showGenerator() ? "☰ hide generator" : "⚙ generate"}513              </button>514              <GeneratorDrawer515                compact516                open={showGenerator()}517                onChange={(pw) => setPassword(pw)}518                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}519              />520            </div>521522            {/* 2FA — optional */}523            <Show when={showTotp() || totp()}>524              <div class="peach-field-card">525                <span class="peach-label">2fa secret</span>526                <div class="peach-field-value-row">527                  <input528                    class="peach-field-value peach-field-value--edit"529                    placeholder="BASE32 or otpauth:// URI"530                    autocomplete="off"531                    value={totp()}532                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}533                  />534                </div>535              </div>536            </Show>537          </section>538        </Show>539540        {/* ── Card fields ── */}541        <Show when={selectedType() === "card"}>542          <section class="peach-fields">543            {/* Nickname */}544            <div class="peach-field-card">545              <span class="peach-label">nickname</span>546              <div class="peach-field-value-row">547                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"548                  autocomplete="off" value={cardNickname()}549                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />550              </div>551            </div>552            {/* Cardholder name */}553            <div class="peach-field-card">554              <span class="peach-label">cardholder name</span>555              <div class="peach-field-value-row">556                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"557                  autocomplete="cc-name" value={cardholderName()}558                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />559              </div>560            </div>561            {/* Card number */}562            <div class="peach-field-card">563              <span class="peach-label">564                card number565                <Show when={getCardNetwork(cardNumber())}>566                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>567                </Show>568              </span>569              <div class="peach-field-value-row">570                <input class="peach-field-value peach-field-value--edit peach-card-number"571                  placeholder="•••• •••• •••• ••••"572                  autocomplete="cc-number" inputmode="numeric" maxLength={19}573                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}574                  onFocus={() => setCardNumberFocused(true)}575                  onBlur={() => setCardNumberFocused(false)}576                  onInput={(e) => {577                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');578                    setCardNumber(raw);579                  }} />580              </div>581            </div>582            {/* Expiry */}583            <div class="peach-field-card">584              <span class="peach-label">expiry</span>585              <div class="peach-card-expiry-row">586                <input class="peach-field-value peach-field-value--edit" placeholder="MM"587                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"588                  value={expMonth()}589                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />590                <span class="peach-card-expiry-sep">/</span>591                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"592                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"593                  value={expYear()}594                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />595              </div>596            </div>597            {/* CVC */}598            <div class="peach-field-card">599              <span class="peach-label">CVC</span>600              <div class="peach-field-value-row">601                <input class="peach-field-value peach-field-value--edit"602                  type={showCvc() ? "text" : "password"} placeholder="•••"603                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}604                  value={cvc()}605                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />606                <button class="peach-inline-action peach-password-toggle-btn" type="button"607                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>608                  <Show when={showCvc()} fallback={609                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">610                      <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" />611                      <circle cx="7" cy="5" r="1.75" />612                    </svg>613                  }>614                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">615                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />616                    </svg>617                  </Show>618                </button>619              </div>620            </div>621          </section>622        </Show>623624        {/* ── Note content ── */}625        <Show when={selectedType() === "note"}>626          <div class="peach-note-body">627            <NoteEditorInline628              initialContent={notes()}629              onContentChange={setNotes}630              onReady={(el) => setNoteEditorEl(el)}631            />632          </div>633        </Show>634635        {/* ── Login notes ── */}636        <Show when={selectedType() === "login" && (showNotes() || notes())}>637          <div class="peach-field-card" style="margin:0 12px">638            <span class="peach-label">notes</span>639            <textarea640              class="peach-new-textarea"641              placeholder="anything worth remembering"642              value={notes()}643              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}644            />645          </div>646        </Show>647648        {/* ── "+ add" optional field links ── */}649        <div class="peach-entry-add-links">650          <Show when={selectedType() === "login" && !showTotp() && !totp()}>651            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>652              + add 2FA653            </button>654          </Show>655          <Show when={selectedType() === "login" && !showNotes() && !notes()}>656            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>657              + add notes658            </button>659          </Show>660        </div>661662        {/* ── Tags ── */}663        <div class="peach-field-card" style="margin:4px 12px 12px">664          <span class="peach-label">tags</span>665          <div class="peach-tag-input-wrap">666            <For each={tags()}>667              {(tag) => (668                <span class="peach-tag-chip">669                  {tag}670                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>671                </span>672              )}673            </For>674            <input675              class="peach-tag-input"676              placeholder={tags().length === 0 ? "add tags…" : ""}677              value={tagInput()}678              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}679              onKeyDown={handleTagKeyDown}680              onFocus={() => setShowTagSuggestions(true)}681              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}682              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"683            />684          </div>685          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>686            <div class="peach-tag-suggestions" style="margin-top:4px;">687              <For each={tagSuggestions()}>688                {(s) => (689                  <button690                    class="peach-tag-suggestion"691                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}692                  >{s}</button>693                )}694              </For>695            </div>696          </Show>697        </div>698699        <Show when={error()}>700          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>701        </Show>702703      </div>704    </div>705  );706}707708interface IdentitySuggestListProps {709  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;710  onPick: (value: string) => void;711  onClose?: () => void;712  listRef?: (el: HTMLDivElement) => void;713  inputRef?: () => HTMLInputElement | undefined;714}715716export function IdentitySuggestList(props: IdentitySuggestListProps) {717  const [collapsed, setCollapsed] = createSignal(false);718  const ChevronIcon = () => (719    <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">720      <path d="M1 1L5 5L9 1" />721    </svg>722  );723724  function handleRowKeyDown(e: KeyboardEvent, value: string) {725    const row = e.currentTarget as HTMLElement;726    if (e.key === "ArrowDown") {727      e.preventDefault();728      (row.nextElementSibling as HTMLElement | null)?.focus();729    } else if (e.key === "ArrowUp") {730      e.preventDefault();731      const prev = row.previousElementSibling as HTMLElement | null;732      if (prev) prev.focus();733      else props.inputRef?.()?.focus();734    } else if (e.key === "Enter" || e.key === " ") {735      e.preventDefault();736      props.onPick(value);737    } else if (e.key === "Escape") {738      e.preventDefault();739      props.onClose?.();740    }741  }742743  return (744    <div class="peach-identity-suggestions" ref={props.listRef}>745      <div class="peach-identity-filter-row" style="justify-content:flex-end">746        <button747          class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}748          type="button"749          onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}750          aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}751        >752          <ChevronIcon />753        </button>754      </div>755      <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>756        <div class="peach-identity-rows-inner">757          <For each={props.suggestions}>758            {(s) => (759              <button760                class="peach-identity-suggestion"761                type="button"762                onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}763                onKeyDown={(e) => handleRowKeyDown(e, s.value)}764              >765                <span class="peach-identity-suggestion-value">{s.value}</span>766                <span class="peach-identity-suggestion-meta">{s.count}×</span>767              </button>768            )}769          </For>770        </div>771      </div>772    </div>773  );774}