Add.tsx

32.1 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 { GeneratorDrawer } from "../components/GeneratorDrawer";67interface AddProps {8  entry?: VaultEntry;9  entries?: VaultEntry[];10  autoOpenGenerator?: boolean;11  onBack: () => void;12  onSave: (entry?: VaultEntry) => void | Promise<void>;13}1415export function AddView(props: AddProps) {16  const [title, setTitle] = createSignal(props.entry?.title ?? "");17  const [username, setUsername] = createSignal(props.entry?.username ?? "");18  const [password, setPassword] = createSignal(props.entry?.password ?? "");19  const [url, setUrl] = createSignal(props.entry?.url ?? "");20  const [totp, setTotp] = createSignal(props.entry?.totp ?? "");21  const [notes, setNotes] = createSignal(props.entry?.notes ?? "");22  const [saving, setSaving] = createSignal(false);23  const [error, setError] = createSignal("");24  const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);25  const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);26  const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);27  const [tagInput, setTagInput] = createSignal("");28  const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);29  const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);30  const [showPassword, setShowPassword] = createSignal(false);31  const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));32  const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));33  const [email, setEmail] = createSignal((props.entry as VaultEntry & { email?: string })?.email ?? "");34  const [activeIdentityField, setActiveIdentityField] = createSignal<"username" | "email" | null>(null);35  const [currentTabUrl, setCurrentTabUrl] = createSignal("");3637  // Mutable refs for keyboard navigation between inputs and suggestion lists.38  // Not signals — focus state doesn't need to be reactive.39  let usernameInputEl: HTMLInputElement | undefined;40  let emailInputEl: HTMLInputElement | undefined;41  let usernameSuggestEl: HTMLDivElement | undefined;42  let emailSuggestEl: HTMLDivElement | undefined;43  let suppressIdentityOpen = false;4445  function openIdentityField(field: "username" | "email") {46    if (suppressIdentityOpen) { suppressIdentityOpen = false; return; }47    setActiveIdentityField(field);48  }4950  const allIdentities = createMemo(() => {51    const emailMap = new Map<string, number>();52    const usernameMap = new Map<string, number>();53    for (const entry of props.entries ?? []) {54      const uname = entry.username?.trim() ?? "";55      const em = entry.email?.trim() ?? "";56      if (uname) {57        if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);58        else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);59      }60      if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);61    }62    const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];63    const seen = new Set<string>();64    for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {65      if (seen.has(v)) continue;66      seen.add(v);67      const ec = emailMap.get(v) ?? 0;68      const uc = usernameMap.get(v) ?? 0;69      result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });70    }71    return result.sort((a, b) => b.count - a.count);72  });7374  const identitySuggestions = createMemo(() => {75    const field = activeIdentityField();76    if (!field) return [];77    const input = (field === "username" ? username() : email()).toLowerCase();78    return allIdentities()79      .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))80      .slice(0, 8);81  });8283  const isCardEntry = () => props.entry?.type === "card";84  const [selectedType, setSelectedType] = createSignal<"login" | "card">(85    isCardEntry() ? "card" : "login"86  );8788  // Card signals89  const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");90  const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");91  const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");92  const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");93  const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");94  const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");95  const [showCvc, setShowCvc] = createSignal(false);96  const [cardNumberFocused, setCardNumberFocused] = createSignal(false);97  function getCardNetwork(num: string): string {98    const n = num.replace(/\s/g, '');99    if (/^4/.test(n)) return 'Visa';100    if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';101    if (/^3[47]/.test(n)) return 'Amex';102    if (/^6(?:011|5)/.test(n)) return 'Discover';103    return '';104  }105106  function formatCardNumberDisplay(raw: string, focused: boolean): string {107    const n = raw.replace(/\s/g, '');108    if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);109    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(' '));110    return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();111  }112113  const canSave = () => {114    if (!title().trim()) return false;115    if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;116    return true;117  };118119  const allTags = createMemo(() => {120    const tagSet = new Set<string>();121    for (const entry of props.entries ?? []) {122      for (const tag of entry.tags ?? []) tagSet.add(tag);123    }124    return Array.from(tagSet).sort();125  });126127  const tagSuggestions = createMemo(() => {128    const input = tagInput().toLowerCase().trim();129    if (!input) return [];130    return allTags().filter(t =>131      t.toLowerCase().includes(input) && !tags().includes(t)132    ).slice(0, 5);133  });134135  function addTag(tag: string) {136    const n = tag.toLowerCase().trim();137    if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;138    setTags([...tags(), n]);139  }140141  function removeTag(tag: string) {142    setTags(tags().filter(t => t !== tag));143  }144145  function handleTagKeyDown(e: KeyboardEvent) {146    if (e.key === "Enter" || e.key === ",") {147      e.preventDefault();148      if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }149    } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {150      removeTag(tags()[tags().length - 1]);151    }152  }153154  // Strength check155  createEffect(async () => {156    const pwd = password();157    if (pwd.length < 4) { setPasswordStrength(null); return; }158    // zxcvbn does full dictionary + spatial + repeat analysis across every159    // character, so a 100+ char password blocks the main thread for hundreds160    // of ms per slider tick. Any 50+ char password is trivially score 4 —161    // skip the analysis and keep the slider responsive.162    if (pwd.length >= 50) { setPasswordStrength(4); return; }163    try {164      if (!zxcvbnFn()) {165        // Replaces the unmaintained `zxcvbn` package (LV-8; see Lock.tsx).166        const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([167          import("@zxcvbn-ts/core"),168          import("@zxcvbn-ts/language-common"),169          import("@zxcvbn-ts/language-en"),170        ]);171        zxcvbnOptions.setOptions({172          translations: en.translations,173          graphs: common.adjacencyGraphs,174          dictionary: { ...common.dictionary, ...en.dictionary },175        });176        setZxcvbnFn(() => (pwd: string) => zxcvbn(pwd));177      }178      const fn = zxcvbnFn();179      if (fn) setPasswordStrength(fn(pwd).score);180    } catch {181      setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);182    }183  });184185  onMount(() => {186    const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;187    if (el) el.focus();188    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {189      const tabUrl = tabs[0]?.url;190      if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);191    });192    // Keep the vault alive while the form is open — the auto-lock alarm fires on193    // a timer and doesn't know the user is actively filling in a new entry.194    const keepAlive = setInterval(() => {195      chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});196    }, 30_000);197    onCleanup(() => clearInterval(keepAlive));198  });199200  function getNormalizedTotp(): string | undefined {201    const value = totp().trim();202    if (!value) return undefined;203    if (value.startsWith("otpauth://")) {204      const parsed = parseOtpauthURI(value);205      return parsed?.secret ?? value;206    }207    return value;208  }209210  async function handleSave() {211    if (!canSave()) {212      setError(213        selectedType() === "card"214          ? "cardholder name and card number are required"215          : "title is required"216      );217      return;218    }219220    setSaving(true);221    setError("");222223    const payload: Record<string, unknown> = {224      type: selectedType(),225      title: title().trim(),226      notes: notes().trim() || undefined,227      tags: tags().length > 0 ? tags() : undefined,228    };229230    if (selectedType() === "login") {231      payload.username = username().trim();232      payload.email = email().trim() || undefined;233      payload.password = password();234      payload.url = url().trim();235      payload.totp = getNormalizedTotp();236    }237238    if (selectedType() === "card") {239      const raw = cardNumber().replace(/\s/g, '');240      payload.cardholderName = cardholderName().trim();241      payload.cardNumber = raw;242      payload.expMonth = expMonth().trim();243      payload.expYear = expYear().trim();244      payload.cvc = cvc().trim();245      payload.cardNickname = cardNickname().trim() || undefined;246      const n = raw;247      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';248      payload.last4 = raw.slice(-4);249    }250251    if (props.entry) {252      const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });253      const result = res as { ok: boolean; error?: string };254      if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }255      await props.onSave({ ...props.entry, ...payload } as VaultEntry);256      return;257    }258259    const res = await sendMessage({ type: "ADD_ENTRY", payload });260    const result = res as { ok: boolean; data?: VaultEntry; error?: string };261    if (!result.ok) {262      // Fixed strings only — raw background errors must not reach the UI.263      if (result.error === "vault_locked") {264        setError("vault is locked — tap the lock icon to re-enter your master password");265      } else {266        // Fixed strings only — raw background errors must not reach the UI.267        const SAFE_ERRORS = new Set([268          "title is required",269          "cardholder name and card number are required",270          "invalid passkey credential",271          "pro_required",272        ]);273        setError(274          result.error !== undefined && SAFE_ERRORS.has(result.error)275            ? result.error276            : "could not save entry",277        );278      }279      setSaving(false);280      return;281    }282    await props.onSave(result.data);283  }284285  const strengthLabel = () => {286    const s = passwordStrength();287    if (s === null) return null;288    return ["weak", "weak", "fair", "good", "strong"][s] as string;289  };290291  return (292    <div class="peach-overlay-view ov-enter-right" style="display:flex">293      <div class="peach-overlay-header">294        <button295          class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"296          onClick={props.onBack}297        >298          cancel299        </button>300        <span class="peach-overlay-title">301          {props.entry ? "edit entry" : "new entry"}302        </span>303        <button304          class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"305          onClick={() => void handleSave()}306          disabled={saving() || !canSave()}307        >308          {saving() ? "saving…" : props.entry ? "save" : "create"}309        </button>310      </div>311312      {/* ── Body — mirrors Entry.tsx field-card structure ── */}313      <div class="peach-entry-body peach-entry-body--new">314315        {/* Type toggle */}316        <Show when={!props.entry}>317          <div class="peach-new-type-row">318            <button319              class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}320              onClick={() => setSelectedType("login")}321            >login</button>322            <button323              class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}324              onClick={() => setSelectedType("card")}325            >card</button>326          </div>327        </Show>328329        {/* Title */}330        <div class="peach-new-title-wrap">331          <input332            class="peach-new-title-input"333            placeholder="entry name"334            autocomplete="off"335            value={title()}336            onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}337            onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}338          />339        </div>340341        {/* ── Login fields ── */}342        <Show when={selectedType() === "login"}>343          <div class="peach-domain">344            <div class="peach-domain-edit-row">345              <input346                class="peach-domain-value peach-domain-value--edit"347                placeholder="domain.com"348                autocomplete="off"349                value={url()}350                onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}351              />352              <Show when={currentTabUrl()}>353                <button354                  class="peach-domain-url-btn"355                  type="button"356                  onClick={() => setUrl(currentTabUrl())}357                  title="Insert current page URL"358                >359                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">360                    <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" />361                    <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" />362                  </svg>363                </button>364              </Show>365            </div>366          </div>367368          <section class="peach-fields">369            {/* Username */}370            <div class="peach-field-card">371              <span class="peach-label">username</span>372              <div class="peach-field-value-row">373                <input374                  ref={(el) => (usernameInputEl = el)}375                  class="peach-field-value peach-field-value--edit"376                  placeholder="johndoe"377                  autocomplete="off"378                  value={username()}379                  onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}380                  onFocus={() => openIdentityField("username")}381                  onBlur={(e) => {382                    if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;383                    setTimeout(() => { if (activeIdentityField() === "username") setActiveIdentityField(null); }, 150);384                  }}385                  onKeyDown={(e) => {386                    if (activeIdentityField() !== "username") return;387                    if (e.key === "ArrowDown") {388                      e.preventDefault();389                      usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();390                    } else if (e.key === "Escape") {391                      e.preventDefault();392                      setActiveIdentityField(null);393                    }394                  }}395                />396              </div>397              <Show when={activeIdentityField() === "username" && identitySuggestions().length > 0}>398                <IdentitySuggestList399                  listRef={(el) => (usernameSuggestEl = el)}400                  inputRef={() => usernameInputEl}401                  suggestions={identitySuggestions()}402                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}403                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}404                />405              </Show>406            </div>407408            {/* Email */}409            <div class="peach-field-card">410              <span class="peach-label">email</span>411              <div class="peach-field-value-row">412                <input413                  ref={(el) => (emailInputEl = el)}414                  class="peach-field-value peach-field-value--edit"415                  type="email"416                  placeholder="[email protected]"417                  autocomplete="off"418                  value={email()}419                  onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}420                  onFocus={() => openIdentityField("email")}421                  onBlur={(e) => {422                    if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;423                    setTimeout(() => { if (activeIdentityField() === "email") setActiveIdentityField(null); }, 150);424                  }}425                  onKeyDown={(e) => {426                    if (activeIdentityField() !== "email") return;427                    if (e.key === "ArrowDown") {428                      e.preventDefault();429                      emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();430                    } else if (e.key === "Escape") {431                      e.preventDefault();432                      setActiveIdentityField(null);433                    }434                  }}435                />436              </div>437              <Show when={activeIdentityField() === "email" && identitySuggestions().length > 0}>438                <IdentitySuggestList439                  listRef={(el) => (emailSuggestEl = el)}440                  inputRef={() => emailInputEl}441                  suggestions={identitySuggestions()}442                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}443                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}444                />445              </Show>446            </div>447448            {/* Password */}449            <div class="peach-field-card">450              <span class="peach-label">password</span>451              <Show when={!showGenerator()}>452                <div class="peach-password-edit-row">453                  <input454                    class="peach-field-value peach-field-value--edit"455                    type={showPassword() ? "text" : "password"}456                    placeholder="password"457                    autocomplete="new-password"458                    value={password()}459                    onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}460                  />461                  <button462                    class="peach-inline-action peach-password-toggle-btn"463                    type="button"464                    onClick={() => setShowPassword(!showPassword())}465                    aria-label={showPassword() ? "Hide password" : "Show password"}466                  >467                    <Show468                      when={showPassword()}469                      fallback={470                        <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">471                          <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" />472                          <circle cx="7" cy="5" r="1.75" />473                        </svg>474                      }475                    >476                      <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">477                        <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />478                        <path d="M3.5 5.5L2.5 7.5" />479                        <path d="M7 6.5V8.5" />480                        <path d="M10.5 5.5L11.5 7.5" />481                      </svg>482                    </Show>483                  </button>484                </div>485              </Show>486487              {/* Strength */}488              <Show when={strengthLabel() !== null}>489                <div class="peach-strength-hairline">490                  <div class="peach-strength-hairline-track">491                    <div492                      class="peach-strength-hairline-fill"493                      style={{494                        width: ["25%","40%","60%","80%","100%"][passwordStrength()!],495                        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()!],496                      }}497                    />498                  </div>499                  <span class="peach-strength-label">{strengthLabel()}</span>500                </div>501              </Show>502503              {/* Generator toggle */}504              <button505                class="peach-generate-toggle-link"506                type="button"507                onClick={() => { setShowGenerator(!showGenerator()); }}508              >509                {showGenerator() ? "☰ hide generator" : "⚙ generate"}510              </button>511              <GeneratorDrawer512                compact513                open={showGenerator()}514                onChange={(pw) => setPassword(pw)}515                onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}516              />517            </div>518519            {/* 2FA — optional */}520            <Show when={showTotp() || totp()}>521              <div class="peach-field-card">522                <span class="peach-label">2fa secret</span>523                <div class="peach-field-value-row">524                  <input525                    class="peach-field-value peach-field-value--edit"526                    placeholder="BASE32 or otpauth:// URI"527                    autocomplete="off"528                    value={totp()}529                    onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}530                  />531                </div>532              </div>533            </Show>534          </section>535        </Show>536537        {/* ── Card fields ── */}538        <Show when={selectedType() === "card"}>539          <section class="peach-fields">540            {/* Nickname */}541            <div class="peach-field-card">542              <span class="peach-label">nickname</span>543              <div class="peach-field-value-row">544                <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"545                  autocomplete="off" value={cardNickname()}546                  onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />547              </div>548            </div>549            {/* Cardholder name */}550            <div class="peach-field-card">551              <span class="peach-label">cardholder name</span>552              <div class="peach-field-value-row">553                <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"554                  autocomplete="cc-name" value={cardholderName()}555                  onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />556              </div>557            </div>558            {/* Card number */}559            <div class="peach-field-card">560              <span class="peach-label">561                card number562                <Show when={getCardNetwork(cardNumber())}>563                  <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>564                </Show>565              </span>566              <div class="peach-field-value-row">567                <input class="peach-field-value peach-field-value--edit peach-card-number"568                  placeholder="•••• •••• •••• ••••"569                  autocomplete="cc-number" inputmode="numeric" maxLength={19}570                  value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}571                  onFocus={() => setCardNumberFocused(true)}572                  onBlur={() => setCardNumberFocused(false)}573                  onInput={(e) => {574                    const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');575                    setCardNumber(raw);576                  }} />577              </div>578            </div>579            {/* Expiry */}580            <div class="peach-field-card">581              <span class="peach-label">expiry</span>582              <div class="peach-card-expiry-row">583                <input class="peach-field-value peach-field-value--edit" placeholder="MM"584                  autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"585                  value={expMonth()}586                  onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />587                <span class="peach-card-expiry-sep">/</span>588                <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"589                  autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"590                  value={expYear()}591                  onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />592              </div>593            </div>594            {/* CVC */}595            <div class="peach-field-card">596              <span class="peach-label">CVC</span>597              <div class="peach-field-value-row">598                <input class="peach-field-value peach-field-value--edit"599                  type={showCvc() ? "text" : "password"} placeholder="•••"600                  autocomplete="cc-csc" inputmode="numeric" maxLength={4}601                  value={cvc()}602                  onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />603                <button class="peach-inline-action peach-password-toggle-btn" type="button"604                  onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>605                  <Show when={showCvc()} fallback={606                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">607                      <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" />608                      <circle cx="7" cy="5" r="1.75" />609                    </svg>610                  }>611                    <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">612                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />613                    </svg>614                  </Show>615                </button>616              </div>617            </div>618          </section>619        </Show>620621        {/* ── Login notes ── */}622        <Show when={selectedType() === "login" && (showNotes() || notes())}>623          <div class="peach-field-card" style="margin:0 12px">624            <span class="peach-label">notes</span>625            <textarea626              class="peach-new-textarea"627              placeholder="anything worth remembering"628              value={notes()}629              onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}630            />631          </div>632        </Show>633634        {/* ── "+ add" optional field links ── */}635        <div class="peach-entry-add-links">636          <Show when={selectedType() === "login" && !showTotp() && !totp()}>637            <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>638              + add 2FA639            </button>640          </Show>641          <Show when={selectedType() === "login" && !showNotes() && !notes()}>642            <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>643              + add notes644            </button>645          </Show>646        </div>647648        {/* ── Tags ── */}649        <div class="peach-field-card" style="margin:4px 12px 12px">650          <span class="peach-label">tags</span>651          <div class="peach-tag-input-wrap">652            <For each={tags()}>653              {(tag) => (654                <span class="peach-tag-chip">655                  {tag}656                  <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>657                </span>658              )}659            </For>660            <input661              class="peach-tag-input"662              placeholder={tags().length === 0 ? "add tags…" : ""}663              value={tagInput()}664              onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}665              onKeyDown={handleTagKeyDown}666              onFocus={() => setShowTagSuggestions(true)}667              onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}668              style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"669            />670          </div>671          <Show when={showTagSuggestions() && tagSuggestions().length > 0}>672            <div class="peach-tag-suggestions" style="margin-top:4px;">673              <For each={tagSuggestions()}>674                {(s) => (675                  <button676                    class="peach-tag-suggestion"677                    onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}678                  >{s}</button>679                )}680              </For>681            </div>682          </Show>683        </div>684685        <Show when={error()}>686          <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>687        </Show>688689      </div>690    </div>691  );692}693694interface IdentitySuggestListProps {695  suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;696  onPick: (value: string) => void;697  onClose?: () => void;698  listRef?: (el: HTMLDivElement) => void;699  inputRef?: () => HTMLInputElement | undefined;700}701702export function IdentitySuggestList(props: IdentitySuggestListProps) {703  const [collapsed, setCollapsed] = createSignal(false);704  const ChevronIcon = () => (705    <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">706      <path d="M1 1L5 5L9 1" />707    </svg>708  );709710  function handleRowKeyDown(e: KeyboardEvent, value: string) {711    const row = e.currentTarget as HTMLElement;712    if (e.key === "ArrowDown") {713      e.preventDefault();714      (row.nextElementSibling as HTMLElement | null)?.focus();715    } else if (e.key === "ArrowUp") {716      e.preventDefault();717      const prev = row.previousElementSibling as HTMLElement | null;718      if (prev) prev.focus();719      else props.inputRef?.()?.focus();720    } else if (e.key === "Enter" || e.key === " ") {721      e.preventDefault();722      props.onPick(value);723    } else if (e.key === "Escape") {724      e.preventDefault();725      props.onClose?.();726    }727  }728729  return (730    <div class="peach-identity-suggestions" ref={props.listRef}>731      <div class="peach-identity-filter-row" style="justify-content:flex-end">732        <button733          class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}734          type="button"735          onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}736          aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}737        >738          <ChevronIcon />739        </button>740      </div>741      <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>742        <div class="peach-identity-rows-inner">743          <For each={props.suggestions}>744            {(s) => (745              <button746                class="peach-identity-suggestion"747                type="button"748                onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}749                onKeyDown={(e) => handleRowKeyDown(e, s.value)}750              >751                <span class="peach-identity-suggestion-value">{s.value}</span>752                <span class="peach-identity-suggestion-meta">{s.count}×</span>753              </button>754            )}755          </For>756        </div>757      </div>758    </div>759  );760}